Pki Generate Intermediate CSR (#18807)
* adds pki generate csr component * adds keyParamsByType helper to pki-generate-toggle-groups component * removes unused router service from pki-generate-csr component * updates common pki generate form fields * addresses feedback and adds tests
This commit is contained in:
parent
bf3e266929
commit
e873d27e83
|
@ -1,5 +1,6 @@
|
||||||
import Model, { attr } from '@ember-data/model';
|
import Model, { attr } from '@ember-data/model';
|
||||||
import { inject as service } from '@ember/service';
|
import { inject as service } from '@ember/service';
|
||||||
|
import { tracked } from '@glimmer/tracking';
|
||||||
import lazyCapabilities, { apiPath } from 'vault/macros/lazy-capabilities';
|
import lazyCapabilities, { apiPath } from 'vault/macros/lazy-capabilities';
|
||||||
import { withFormFields } from 'vault/decorators/model-form-fields';
|
import { withFormFields } from 'vault/decorators/model-form-fields';
|
||||||
import { withModelValidations } from 'vault/decorators/model-validations';
|
import { withModelValidations } from 'vault/decorators/model-validations';
|
||||||
|
@ -10,7 +11,7 @@ const validations = {
|
||||||
issuerName: [
|
issuerName: [
|
||||||
{
|
{
|
||||||
validator(model) {
|
validator(model) {
|
||||||
if (model.issuerName === 'default') return false;
|
if (model.actionType === 'generate-root' && model.issuerName === 'default') return false;
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
message: 'Issuer name must be unique across all issuers and not be the reserved value default.',
|
message: 'Issuer name must be unique across all issuers and not be the reserved value default.',
|
||||||
|
@ -23,6 +24,8 @@ const validations = {
|
||||||
export default class PkiActionModel extends Model {
|
export default class PkiActionModel extends Model {
|
||||||
@service secretMountPath;
|
@service secretMountPath;
|
||||||
|
|
||||||
|
@tracked actionType; // used to toggle between different form fields when creating configuration
|
||||||
|
|
||||||
/* actionType import */
|
/* actionType import */
|
||||||
@attr('string') pemBundle;
|
@attr('string') pemBundle;
|
||||||
|
|
||||||
|
@ -33,7 +36,7 @@ export default class PkiActionModel extends Model {
|
||||||
})
|
})
|
||||||
type;
|
type;
|
||||||
|
|
||||||
@attr('string') issuerName; // REQUIRED, cannot be "default"
|
@attr('string') issuerName; // REQUIRED for generate-root actionType, cannot be "default"
|
||||||
|
|
||||||
@attr('string') keyName; // cannot be "default"
|
@attr('string') keyName; // cannot be "default"
|
||||||
|
|
||||||
|
@ -123,6 +126,11 @@ export default class PkiActionModel extends Model {
|
||||||
})
|
})
|
||||||
serialNumber;
|
serialNumber;
|
||||||
|
|
||||||
|
@attr('boolean', {
|
||||||
|
subText: 'Whether to add a Basic Constraints extension with CA: true.',
|
||||||
|
})
|
||||||
|
addBasicConstraints;
|
||||||
|
|
||||||
@attr({
|
@attr({
|
||||||
label: 'Backdate validity',
|
label: 'Backdate validity',
|
||||||
detailsLabel: 'Issued certificate backdating',
|
detailsLabel: 'Issued certificate backdating',
|
||||||
|
|
|
@ -25,35 +25,41 @@ export default class PkiActionSerializer extends ApplicationSerializer {
|
||||||
|
|
||||||
_allowedParamsByType(actionType, type) {
|
_allowedParamsByType(actionType, type) {
|
||||||
const keyFields = keyParamsByType(type).map((attrName) => underscore(attrName).toLowerCase());
|
const keyFields = keyParamsByType(type).map((attrName) => underscore(attrName).toLowerCase());
|
||||||
switch (actionType) {
|
const commonProps = [
|
||||||
case 'import':
|
|
||||||
return ['pem_bundle'];
|
|
||||||
case 'generate-root':
|
|
||||||
return [
|
|
||||||
'alt_names',
|
'alt_names',
|
||||||
'common_name',
|
'common_name',
|
||||||
'country',
|
'country',
|
||||||
'exclude_cn_from_sans',
|
'exclude_cn_from_sans',
|
||||||
'format',
|
'format',
|
||||||
'ip_sans',
|
'ip_sans',
|
||||||
'issuer_name',
|
|
||||||
'locality',
|
'locality',
|
||||||
'max_path_length',
|
|
||||||
'not_after',
|
|
||||||
'not_before_duration',
|
|
||||||
'organization',
|
'organization',
|
||||||
'other_sans',
|
'other_sans',
|
||||||
'ou',
|
'ou',
|
||||||
'permitted_dns_domains',
|
|
||||||
'postal_code',
|
'postal_code',
|
||||||
'private_key_format',
|
|
||||||
'province',
|
'province',
|
||||||
'serial_number',
|
'serial_number',
|
||||||
'street_address',
|
'street_address',
|
||||||
'ttl',
|
|
||||||
'type',
|
'type',
|
||||||
|
'uri_sans',
|
||||||
...keyFields,
|
...keyFields,
|
||||||
];
|
];
|
||||||
|
switch (actionType) {
|
||||||
|
case 'import':
|
||||||
|
return ['pem_bundle'];
|
||||||
|
case 'generate-root':
|
||||||
|
return [
|
||||||
|
...commonProps,
|
||||||
|
'issuer_name',
|
||||||
|
'max_path_length',
|
||||||
|
'not_after',
|
||||||
|
'not_before_duration',
|
||||||
|
'permitted_dns_domains',
|
||||||
|
'private_key_format',
|
||||||
|
'ttl',
|
||||||
|
];
|
||||||
|
case 'generate-csr':
|
||||||
|
return [...commonProps, 'add_basic_constraints'];
|
||||||
default:
|
default:
|
||||||
// if type doesn't match, serialize all
|
// if type doesn't match, serialize all
|
||||||
return null;
|
return null;
|
||||||
|
|
|
@ -304,7 +304,7 @@
|
||||||
{{on "input" this.onChangeWithEvent}}
|
{{on "input" this.onChangeWithEvent}}
|
||||||
{{on "change" this.onChangeWithEvent}}
|
{{on "change" this.onChangeWithEvent}}
|
||||||
{{on "keyup" this.handleKeyUp}}
|
{{on "keyup" this.handleKeyUp}}
|
||||||
class="input {{if this.validationError 'has-error-border'}} has-bottom-margin-m"
|
class="input {{if this.validationError 'has-error-border' 'has-bottom-margin-m'}}"
|
||||||
maxLength={{@attr.options.characterLimit}}
|
maxLength={{@attr.options.characterLimit}}
|
||||||
/>
|
/>
|
||||||
{{#if @attr.options.validationAttr}}
|
{{#if @attr.options.validationAttr}}
|
||||||
|
|
|
@ -2,7 +2,7 @@
|
||||||
<div class="columns">
|
<div class="columns">
|
||||||
{{#each this.configTypes as |option|}}
|
{{#each this.configTypes as |option|}}
|
||||||
<div class="column is-flex">
|
<div class="column is-flex">
|
||||||
<label for={{option.key}} class="box-label is-column {{if (eq this.actionType option.key) 'is-selected'}}">
|
<label for={{option.key}} class="box-label is-column {{if (eq @config.actionType option.key) 'is-selected'}}">
|
||||||
<div>
|
<div>
|
||||||
<h3 class="box-label-header title is-6">
|
<h3 class="box-label-header title is-6">
|
||||||
<Icon @size="24" @name={{option.icon}} />
|
<Icon @size="24" @name={{option.icon}} />
|
||||||
|
@ -17,8 +17,8 @@
|
||||||
id={{option.key}}
|
id={{option.key}}
|
||||||
name="pki-config-type"
|
name="pki-config-type"
|
||||||
@value={{option.key}}
|
@value={{option.key}}
|
||||||
@groupValue={{this.actionType}}
|
@groupValue={{@config.actionType}}
|
||||||
@onChange={{fn (mut this.actionType) option.key}}
|
@onChange={{fn (mut @config.actionType) option.key}}
|
||||||
data-test-pki-config-option={{option.key}}
|
data-test-pki-config-option={{option.key}}
|
||||||
/>
|
/>
|
||||||
<label for={{option.key}}></label>
|
<label for={{option.key}}></label>
|
||||||
|
@ -27,22 +27,26 @@
|
||||||
</div>
|
</div>
|
||||||
{{/each}}
|
{{/each}}
|
||||||
</div>
|
</div>
|
||||||
{{#if (eq this.actionType "import")}}
|
{{#if (eq @config.actionType "import")}}
|
||||||
<PkiCaCertificateImport
|
<PkiCaCertificateImport
|
||||||
@model={{@config}}
|
@model={{@config}}
|
||||||
@onCancel={{transition-to "vault.cluster.secrets.backend.pki.overview"}}
|
@onCancel={{transition-to "vault.cluster.secrets.backend.pki.overview"}}
|
||||||
@onSave={{transition-to "vault.cluster.secrets.backend.pki.issuers"}}
|
@onSave={{transition-to "vault.cluster.secrets.backend.pki.issuers"}}
|
||||||
@adapterOptions={{hash actionType=this.actionType useIssuer=@config.canImportBundle}}
|
@adapterOptions={{hash actionType=@config.actionType useIssuer=@config.canImportBundle}}
|
||||||
/>
|
/>
|
||||||
{{else if (eq this.actionType "generate-root")}}
|
{{else if (eq @config.actionType "generate-root")}}
|
||||||
<PkiGenerateRoot
|
<PkiGenerateRoot
|
||||||
@model={{@config}}
|
@model={{@config}}
|
||||||
@urls={{@urls}}
|
@urls={{@urls}}
|
||||||
@onCancel={{this.cancel}}
|
@onCancel={{this.cancel}}
|
||||||
@adapterOptions={{hash actionType="generate-root" useIssuer=@config.canGenerateIssuerRoot}}
|
@adapterOptions={{hash actionType=@config.actionType useIssuer=@config.canGenerateIssuerRoot}}
|
||||||
|
/>
|
||||||
|
{{else if (eq @config.actionType "generate-csr")}}
|
||||||
|
<PkiGenerateCsr
|
||||||
|
@model={{@config}}
|
||||||
|
@onSave={{transition-to "vault.cluster.secrets.backend.pki.issuers"}}
|
||||||
|
@onCancel={{this.cancel}}
|
||||||
/>
|
/>
|
||||||
{{else if (eq this.actionType "generate-csr")}}
|
|
||||||
<code>POST /intermediate/generate/:type ~or~ /issuers/generate/intermediate/:type</code>
|
|
||||||
{{else}}
|
{{else}}
|
||||||
<EmptyState
|
<EmptyState
|
||||||
@title="Choose an option"
|
@title="Choose an option"
|
||||||
|
|
|
@ -4,7 +4,6 @@ import { inject as service } from '@ember/service';
|
||||||
import Store from '@ember-data/store';
|
import Store from '@ember-data/store';
|
||||||
import Router from '@ember/routing/router';
|
import Router from '@ember/routing/router';
|
||||||
import FlashMessageService from 'vault/services/flash-messages';
|
import FlashMessageService from 'vault/services/flash-messages';
|
||||||
import { tracked } from '@glimmer/tracking';
|
|
||||||
import { action } from '@ember/object';
|
import { action } from '@ember/object';
|
||||||
import PkiActionModel from 'vault/models/pki/action';
|
import PkiActionModel from 'vault/models/pki/action';
|
||||||
|
|
||||||
|
@ -23,7 +22,6 @@ export default class PkiConfigureForm extends Component<Args> {
|
||||||
@service declare readonly store: Store;
|
@service declare readonly store: Store;
|
||||||
@service declare readonly router: Router;
|
@service declare readonly router: Router;
|
||||||
@service declare readonly flashMessages: FlashMessageService;
|
@service declare readonly flashMessages: FlashMessageService;
|
||||||
@tracked actionType = '';
|
|
||||||
|
|
||||||
get configTypes() {
|
get configTypes() {
|
||||||
return [
|
return [
|
||||||
|
@ -51,24 +49,6 @@ export default class PkiConfigureForm extends Component<Args> {
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
shouldUseIssuerEndpoint() {
|
|
||||||
const { config } = this.args;
|
|
||||||
// To determine which endpoint the config adapter should use,
|
|
||||||
// we want to check capabilities on the newer endpoints (those
|
|
||||||
// prefixed with "issuers") and use the old path as fallback
|
|
||||||
// if user does not have permissions.
|
|
||||||
switch (this.actionType) {
|
|
||||||
case 'import':
|
|
||||||
return config.canImportBundle;
|
|
||||||
case 'generate-root':
|
|
||||||
return config.canGenerateIssuerRoot;
|
|
||||||
case 'generate-csr':
|
|
||||||
return config.canGenerateIssuerIntermediate;
|
|
||||||
default:
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@action cancel() {
|
@action cancel() {
|
||||||
this.args.config.rollbackAttributes();
|
this.args.config.rollbackAttributes();
|
||||||
this.router.transitionTo('vault.cluster.secrets.backend.pki.overview');
|
this.router.transitionTo('vault.cluster.secrets.backend.pki.overview');
|
||||||
|
|
|
@ -0,0 +1,28 @@
|
||||||
|
<form {{on "submit" (perform this.save)}}>
|
||||||
|
<MessageError @errorMessage={{this.error}} class="has-top-margin-s" />
|
||||||
|
<h2 class="title is-size-5 has-border-bottom-light page-header">
|
||||||
|
CSR parameters
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
{{#each this.formFields as |field|}}
|
||||||
|
<FormField @attr={{field}} @model={{@model}} @modelValidations={{this.modelValidations}} />
|
||||||
|
{{/each}}
|
||||||
|
|
||||||
|
<PkiGenerateToggleGroups @model={{@model}} />
|
||||||
|
|
||||||
|
<div class="field is-grouped box is-fullwidth is-bottomless has-top-margin-l">
|
||||||
|
<div class="control">
|
||||||
|
<button type="submit" class="button is-primary" data-test-save>
|
||||||
|
Done
|
||||||
|
</button>
|
||||||
|
<button {{on "click" this.cancel}} type="button" class="button has-left-margin-s" data-test-cancel>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{{#if this.alert}}
|
||||||
|
<div class="control">
|
||||||
|
<AlertInline @type="danger" @paddingTop={{true}} @message={{this.alert}} @mimicRefresh={{true}} data-test-alert />
|
||||||
|
</div>
|
||||||
|
{{/if}}
|
||||||
|
</div>
|
||||||
|
</form>
|
|
@ -0,0 +1,76 @@
|
||||||
|
import Component from '@glimmer/component';
|
||||||
|
import { tracked } from '@glimmer/tracking';
|
||||||
|
import { inject as service } from '@ember/service';
|
||||||
|
import { action } from '@ember/object';
|
||||||
|
import { task } from 'ember-concurrency';
|
||||||
|
import { waitFor } from '@ember/test-waiters';
|
||||||
|
import { expandAttributeMeta } from 'vault/utils/field-to-attrs';
|
||||||
|
import FlashMessageService from 'vault/services/flash-messages';
|
||||||
|
import PkiActionModel from 'vault/models/pki/action';
|
||||||
|
import errorMessage from 'vault/utils/error-message';
|
||||||
|
|
||||||
|
interface Args {
|
||||||
|
model: PkiActionModel;
|
||||||
|
useIssuer: boolean;
|
||||||
|
onSave: CallableFunction;
|
||||||
|
onCancel: CallableFunction;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default class PkiGenerateIntermediateComponent extends Component<Args> {
|
||||||
|
@service declare readonly flashMessages: FlashMessageService;
|
||||||
|
|
||||||
|
@tracked modelValidations = null;
|
||||||
|
@tracked error: string | null = null;
|
||||||
|
@tracked alert: string | null = null;
|
||||||
|
|
||||||
|
formFields;
|
||||||
|
|
||||||
|
constructor(owner: unknown, args: Args) {
|
||||||
|
super(owner, args);
|
||||||
|
this.formFields = expandAttributeMeta(this.args.model, [
|
||||||
|
'type',
|
||||||
|
'commonName',
|
||||||
|
'excludeCnFromSans',
|
||||||
|
'format',
|
||||||
|
'serialNumber',
|
||||||
|
'addBasicConstraints',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
cancel() {
|
||||||
|
this.args.model.unloadRecord();
|
||||||
|
this.args.onCancel();
|
||||||
|
}
|
||||||
|
|
||||||
|
async getCapability(): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const issuerCapabilities = await this.args.model.generateIssuerCsrPath;
|
||||||
|
return issuerCapabilities.get('canCreate') === true;
|
||||||
|
} catch (error) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@task
|
||||||
|
@waitFor
|
||||||
|
*save(event: Event): Generator<Promise<boolean | PkiActionModel>> {
|
||||||
|
event.preventDefault();
|
||||||
|
try {
|
||||||
|
const { model, onSave } = this.args;
|
||||||
|
const { isValid, state, invalidFormMessage } = model.validate();
|
||||||
|
if (isValid) {
|
||||||
|
const useIssuer = yield this.getCapability();
|
||||||
|
yield model.save({ adapterOptions: { actionType: 'generate-csr', useIssuer } });
|
||||||
|
this.flashMessages.success('Successfully generated CSR.');
|
||||||
|
onSave();
|
||||||
|
} else {
|
||||||
|
this.modelValidations = state;
|
||||||
|
this.alert = invalidFormMessage;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
this.error = errorMessage(e);
|
||||||
|
this.alert = 'There was a problem generating the CSR.';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -20,58 +20,7 @@
|
||||||
{{/let}}
|
{{/let}}
|
||||||
{{/each}}
|
{{/each}}
|
||||||
|
|
||||||
{{! togglable groups }}
|
<PkiGenerateToggleGroups @model={{@model}} />
|
||||||
{{#each-in this.groups as |group fields|}}
|
|
||||||
<ToggleButton
|
|
||||||
@isOpen={{eq this.showGroup group}}
|
|
||||||
@openLabel={{concat "Hide " group}}
|
|
||||||
@closedLabel={{group}}
|
|
||||||
@onClick={{fn this.toggleGroup group}}
|
|
||||||
class="is-block"
|
|
||||||
data-test-toggle-group={{group}}
|
|
||||||
/>
|
|
||||||
{{#if (eq this.showGroup group)}}
|
|
||||||
<div class="box is-marginless" data-test-group={{group}}>
|
|
||||||
{{#if (eq group "Key parameters")}}
|
|
||||||
<p class="has-bottom-margin-m" data-test-toggle-group-description>
|
|
||||||
{{#if (eq @model.type "internal")}}
|
|
||||||
This certificate type is internal. This means that the private key will not be returned and cannot be retrieved
|
|
||||||
later. Below, you will name the key and define its type and key bits.
|
|
||||||
{{else if (eq @model.type "kms")}}
|
|
||||||
This certificate type is kms, meaning managed keys will be used. Below, you will name the key and tell Vault
|
|
||||||
where to find it in your KMS or HSM.
|
|
||||||
<DocLink @path="/vault/docs/enterprise/managed-keys">Learn more about managed keys.</DocLink>
|
|
||||||
{{else if (eq @model.type "exported")}}
|
|
||||||
This certificate type is exported. This means the private key will be returned in the response. Below, you will
|
|
||||||
name the key and define its type and key bits.
|
|
||||||
{{else if (eq @model.type "existing")}}
|
|
||||||
You chose to use an existing key. This means that we’ll use the key reference to create the CSR or root. Please
|
|
||||||
provide the reference to the key.
|
|
||||||
{{else}}
|
|
||||||
Please choose a type to see key parameter options.
|
|
||||||
{{/if}}
|
|
||||||
</p>
|
|
||||||
{{#if this.keyParamFields}}
|
|
||||||
<PkiKeyParameters @model={{@model}} @fields={{this.keyParamFields}} />
|
|
||||||
{{/if}}
|
|
||||||
{{else}}
|
|
||||||
<p class="has-bottom-margin-m" data-test-toggle-group-description>
|
|
||||||
{{#if (eq group "Subject Alternative Name (SAN) Options")}}
|
|
||||||
SAN fields are an extension that allow you specify additional host names (sites, IP addresses, common names,
|
|
||||||
etc.) to be protected by a single certificate.
|
|
||||||
{{else if (eq group "Additional subject fields")}}
|
|
||||||
These fields provide more information about the client to which the certificate belongs.
|
|
||||||
{{/if}}
|
|
||||||
</p>
|
|
||||||
{{#each fields as |fieldName|}}
|
|
||||||
{{#let (find-by "name" fieldName @model.allFields) as |attr|}}
|
|
||||||
<FormField data-test-field @attr={{attr}} @mode="create" @model={{@model}} />
|
|
||||||
{{/let}}
|
|
||||||
{{/each}}
|
|
||||||
{{/if}}
|
|
||||||
</div>
|
|
||||||
{{/if}}
|
|
||||||
{{/each-in}}
|
|
||||||
|
|
||||||
{{#if @urls}}
|
{{#if @urls}}
|
||||||
<fieldset class="box is-shadowless is-marginless is-borderless is-fullwidth" data-test-urls-section>
|
<fieldset class="box is-shadowless is-marginless is-borderless is-fullwidth" data-test-urls-section>
|
||||||
|
|
|
@ -4,7 +4,6 @@ import { waitFor } from '@ember/test-waiters';
|
||||||
import Component from '@glimmer/component';
|
import Component from '@glimmer/component';
|
||||||
import { tracked } from '@glimmer/tracking';
|
import { tracked } from '@glimmer/tracking';
|
||||||
import { task } from 'ember-concurrency';
|
import { task } from 'ember-concurrency';
|
||||||
import { keyParamsByType } from 'pki/utils/action-params';
|
|
||||||
import errorMessage from 'vault/utils/error-message';
|
import errorMessage from 'vault/utils/error-message';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -32,11 +31,6 @@ export default class PkiGenerateRootComponent extends Component {
|
||||||
@tracked errorBanner = '';
|
@tracked errorBanner = '';
|
||||||
@tracked invalidFormAlert = '';
|
@tracked invalidFormAlert = '';
|
||||||
|
|
||||||
@action
|
|
||||||
toggleGroup(group, isOpen) {
|
|
||||||
this.showGroup = isOpen ? group : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
get defaultFields() {
|
get defaultFields() {
|
||||||
return [
|
return [
|
||||||
'type',
|
'type',
|
||||||
|
@ -49,14 +43,6 @@ export default class PkiGenerateRootComponent extends Component {
|
||||||
'maxPathLength',
|
'maxPathLength',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
get keyParamFields() {
|
|
||||||
const { type } = this.args.model;
|
|
||||||
if (!type) return null;
|
|
||||||
const fields = keyParamsByType(type);
|
|
||||||
return fields.map((fieldName) => {
|
|
||||||
return this.args.model.allFields.find((attr) => attr.name === fieldName);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
@action cancel() {
|
@action cancel() {
|
||||||
// Generate root form will always have a new model
|
// Generate root form will always have a new model
|
||||||
|
@ -64,29 +50,6 @@ export default class PkiGenerateRootComponent extends Component {
|
||||||
this.args.onCancel();
|
this.args.onCancel();
|
||||||
}
|
}
|
||||||
|
|
||||||
get groups() {
|
|
||||||
return {
|
|
||||||
'Key parameters': this.keyParamFields,
|
|
||||||
'Subject Alternative Name (SAN) Options': [
|
|
||||||
'excludeCnFromSans',
|
|
||||||
'serialNumber',
|
|
||||||
'altNames',
|
|
||||||
'ipSans',
|
|
||||||
'uriSans',
|
|
||||||
'otherSans',
|
|
||||||
],
|
|
||||||
'Additional subject fields': [
|
|
||||||
'ou',
|
|
||||||
'organization',
|
|
||||||
'country',
|
|
||||||
'locality',
|
|
||||||
'province',
|
|
||||||
'streetAddress',
|
|
||||||
'postalCode',
|
|
||||||
],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
@action
|
@action
|
||||||
checkFormValidity() {
|
checkFormValidity() {
|
||||||
if (this.args.model.validate) {
|
if (this.args.model.validate) {
|
||||||
|
|
|
@ -0,0 +1,51 @@
|
||||||
|
{{#each-in this.groups as |group fields|}}
|
||||||
|
<ToggleButton
|
||||||
|
@isOpen={{eq this.showGroup group}}
|
||||||
|
@openLabel={{concat "Hide " group}}
|
||||||
|
@closedLabel={{group}}
|
||||||
|
@onClick={{fn this.toggleGroup group}}
|
||||||
|
class="is-block"
|
||||||
|
data-test-toggle-group={{group}}
|
||||||
|
/>
|
||||||
|
{{#if (eq this.showGroup group)}}
|
||||||
|
<div class="box is-marginless" data-test-group={{group}}>
|
||||||
|
{{#if (eq group "Key parameters")}}
|
||||||
|
<p class="has-bottom-margin-m" data-test-toggle-group-description>
|
||||||
|
{{#if (eq @model.type "internal")}}
|
||||||
|
This certificate type is internal. This means that the private key will not be returned and cannot be retrieved
|
||||||
|
later. Below, you will name the key and define its type and key bits.
|
||||||
|
{{else if (eq @model.type "kms")}}
|
||||||
|
This certificate type is kms, meaning managed keys will be used. Below, you will name the key and tell Vault
|
||||||
|
where to find it in your KMS or HSM.
|
||||||
|
<DocLink @path="/vault/docs/enterprise/managed-keys">Learn more about managed keys.</DocLink>
|
||||||
|
{{else if (eq @model.type "exported")}}
|
||||||
|
This certificate type is exported. This means the private key will be returned in the response. Below, you will
|
||||||
|
name the key and define its type and key bits.
|
||||||
|
{{else if (eq @model.type "existing")}}
|
||||||
|
You chose to use an existing key. This means that we’ll use the key reference to create the CSR or root. Please
|
||||||
|
provide the reference to the key.
|
||||||
|
{{else}}
|
||||||
|
Please choose a type to see key parameter options.
|
||||||
|
{{/if}}
|
||||||
|
</p>
|
||||||
|
{{#if this.keyParamFields}}
|
||||||
|
<PkiKeyParameters @model={{@model}} @fields={{this.keyParamFields}} />
|
||||||
|
{{/if}}
|
||||||
|
{{else}}
|
||||||
|
<p class="has-bottom-margin-m" data-test-toggle-group-description>
|
||||||
|
{{#if (eq group "Subject Alternative Name (SAN) Options")}}
|
||||||
|
SAN fields are an extension that allow you specify additional host names (sites, IP addresses, common names,
|
||||||
|
etc.) to be protected by a single certificate.
|
||||||
|
{{else if (eq group "Additional subject fields")}}
|
||||||
|
These fields provide more information about the client to which the certificate belongs.
|
||||||
|
{{/if}}
|
||||||
|
</p>
|
||||||
|
{{#each fields as |fieldName|}}
|
||||||
|
{{#let (find-by "name" fieldName @model.allFields) as |attr|}}
|
||||||
|
<FormField data-test-field @attr={{attr}} @mode="create" @model={{@model}} />
|
||||||
|
{{/let}}
|
||||||
|
{{/each}}
|
||||||
|
{{/if}}
|
||||||
|
</div>
|
||||||
|
{{/if}}
|
||||||
|
{{/each-in}}
|
|
@ -0,0 +1,48 @@
|
||||||
|
import Component from '@glimmer/component';
|
||||||
|
import { tracked } from '@glimmer/tracking';
|
||||||
|
import { action } from '@ember/object';
|
||||||
|
import { keyParamsByType } from 'pki/utils/action-params';
|
||||||
|
import PkiActionModel from 'vault/models/pki/action';
|
||||||
|
|
||||||
|
interface Args {
|
||||||
|
model: PkiActionModel;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default class PkiGenerateToggleGroupsComponent extends Component<Args> {
|
||||||
|
@tracked showGroup: string | null = null;
|
||||||
|
|
||||||
|
get keyParamFields() {
|
||||||
|
const { type } = this.args.model;
|
||||||
|
if (!type) return null;
|
||||||
|
const fields = keyParamsByType(type);
|
||||||
|
return fields.map((fieldName) => {
|
||||||
|
return this.args.model.allFields.find((attr) => attr.name === fieldName);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
get groups() {
|
||||||
|
const groups = {
|
||||||
|
'Key parameters': this.keyParamFields,
|
||||||
|
'Subject Alternative Name (SAN) Options': ['altNames', 'ipSans', 'uriSans', 'otherSans'],
|
||||||
|
'Additional subject fields': [
|
||||||
|
'ou',
|
||||||
|
'organization',
|
||||||
|
'country',
|
||||||
|
'locality',
|
||||||
|
'province',
|
||||||
|
'streetAddress',
|
||||||
|
'postalCode',
|
||||||
|
],
|
||||||
|
};
|
||||||
|
// excludeCnFromSans and serialNumber are present in default fields for generate-csr -- only include for other types
|
||||||
|
if (this.args.model.actionType !== 'generate-csr') {
|
||||||
|
groups['Subject Alternative Name (SAN) Options'].unshift('excludeCnFromSans', 'serialNumber');
|
||||||
|
}
|
||||||
|
return groups;
|
||||||
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
toggleGroup(group: string, isOpen: boolean) {
|
||||||
|
this.showGroup = isOpen ? group : null;
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,3 +1,12 @@
|
||||||
import Route from '@ember/routing/route';
|
import PkiIssuersIndexRoute from '.';
|
||||||
|
|
||||||
export default class PkiIssuersGenerateIntermediateRoute extends Route {}
|
export default class PkiIssuersGenerateIntermediateRoute extends PkiIssuersIndexRoute {
|
||||||
|
model() {
|
||||||
|
return this.store.createRecord('pki/action', { actionType: 'generate-csr' });
|
||||||
|
}
|
||||||
|
|
||||||
|
setupController(controller, resolvedModel) {
|
||||||
|
super.setupController(controller, resolvedModel);
|
||||||
|
controller.breadcrumbs.push({ label: 'generate CSR' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -1 +1,16 @@
|
||||||
route: issuers.generate-intermediate
|
<PageHeader as |p|>
|
||||||
|
<p.top>
|
||||||
|
<Page::Breadcrumbs @breadcrumbs={{this.breadcrumbs}} />
|
||||||
|
</p.top>
|
||||||
|
<p.levelLeft>
|
||||||
|
<h1 class="title is-3">
|
||||||
|
Generate intermediate CSR
|
||||||
|
</h1>
|
||||||
|
</p.levelLeft>
|
||||||
|
</PageHeader>
|
||||||
|
|
||||||
|
<PkiGenerateCsr
|
||||||
|
@model={{this.model}}
|
||||||
|
@onCancel={{transition-to "vault.cluster.secrets.backend.pki.issuers.index"}}
|
||||||
|
@onSave={{transition-to "vault.cluster.secrets.backend.pki.issuers.index"}}
|
||||||
|
/>
|
|
@ -127,6 +127,21 @@ module('Acceptance | pki workflow', function (hooks) {
|
||||||
assert.dom(SELECTORS.issuerDetails.valueByName('Common name')).hasText('my-common-name');
|
assert.dom(SELECTORS.issuerDetails.valueByName('Common name')).hasText('my-common-name');
|
||||||
assert.dom(SELECTORS.issuerDetails.valueByName('Issuer name')).hasText('my-first-issuer');
|
assert.dom(SELECTORS.issuerDetails.valueByName('Issuer name')).hasText('my-first-issuer');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('it should generate intermediate csr', async function (assert) {
|
||||||
|
await authPage.login(this.pkiAdminToken);
|
||||||
|
await visit(`/vault/secrets/${this.mountPath}/pki/overview`);
|
||||||
|
await click(SELECTORS.emptyStateLink);
|
||||||
|
await click(SELECTORS.configuration.optionByKey('generate-csr'));
|
||||||
|
await fillIn(SELECTORS.configuration.typeField, 'exported');
|
||||||
|
await fillIn(SELECTORS.configuration.inputByName('commonName'), 'my-common-name');
|
||||||
|
await click('[data-test-save]');
|
||||||
|
assert.strictEqual(
|
||||||
|
currentURL(),
|
||||||
|
`/vault/secrets/${this.mountPath}/pki/issuers`,
|
||||||
|
'Transitions to issuers on save success'
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
module('roles', function (hooks) {
|
module('roles', function (hooks) {
|
||||||
|
|
|
@ -0,0 +1,81 @@
|
||||||
|
import { module, test } from 'qunit';
|
||||||
|
import { setupRenderingTest } from 'vault/tests/helpers';
|
||||||
|
import { click, fillIn, render } from '@ember/test-helpers';
|
||||||
|
import { hbs } from 'ember-cli-htmlbars';
|
||||||
|
import { setupEngine } from 'ember-engines/test-support';
|
||||||
|
import { setupMirage } from 'ember-cli-mirage/test-support';
|
||||||
|
|
||||||
|
module('Integration | Component | PkiGenerateCsr', function (hooks) {
|
||||||
|
setupRenderingTest(hooks);
|
||||||
|
setupEngine(hooks, 'pki');
|
||||||
|
setupMirage(hooks);
|
||||||
|
|
||||||
|
hooks.beforeEach(async function () {
|
||||||
|
this.owner.lookup('service:secretMountPath').update('pki-test');
|
||||||
|
this.model = this.owner
|
||||||
|
.lookup('service:store')
|
||||||
|
.createRecord('pki/action', { actionType: 'generate-csr' });
|
||||||
|
|
||||||
|
this.server.post('/sys/capabilities-self', () => ({
|
||||||
|
data: {
|
||||||
|
capabilities: ['root'],
|
||||||
|
'pki-test/issuers/generate/intermediate/exported': ['root'],
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('it should render fields and save', async function (assert) {
|
||||||
|
assert.expect(9);
|
||||||
|
|
||||||
|
this.server.post('/pki-test/issuers/generate/intermediate/exported', (schema, req) => {
|
||||||
|
const payload = JSON.parse(req.requestBody);
|
||||||
|
assert.strictEqual(payload.common_name, 'foo', 'Request made to correct endpoint on save');
|
||||||
|
});
|
||||||
|
|
||||||
|
this.onSave = () => assert.ok(true, 'onSave action fires');
|
||||||
|
|
||||||
|
await render(hbs`<PkiGenerateCsr @model={{this.model}} @onSave={{this.onSave}} />`, {
|
||||||
|
owner: this.engine,
|
||||||
|
});
|
||||||
|
|
||||||
|
const fields = [
|
||||||
|
'type',
|
||||||
|
'commonName',
|
||||||
|
'excludeCnFromSans',
|
||||||
|
'format',
|
||||||
|
'serialNumber',
|
||||||
|
'addBasicConstraints',
|
||||||
|
];
|
||||||
|
fields.forEach((key) => {
|
||||||
|
assert.dom(`[data-test-input="${key}"]`).exists(`${key} form field renders`);
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.dom('[data-test-toggle-group]').exists({ count: 3 }, 'Toggle groups render');
|
||||||
|
|
||||||
|
await fillIn('[data-test-input="type"]', 'exported');
|
||||||
|
await fillIn('[data-test-input="commonName"]', 'foo');
|
||||||
|
await click('[data-test-save]');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('it should display validation errors', async function (assert) {
|
||||||
|
assert.expect(4);
|
||||||
|
|
||||||
|
this.onCancel = () => assert.ok(true, 'onCancel action fires');
|
||||||
|
|
||||||
|
await render(hbs`<PkiGenerateCsr @model={{this.model}} @onCancel={{this.onCancel}} />`, {
|
||||||
|
owner: this.engine,
|
||||||
|
});
|
||||||
|
|
||||||
|
await click('[data-test-save]');
|
||||||
|
|
||||||
|
assert
|
||||||
|
.dom('[data-test-field-validation="type"]')
|
||||||
|
.hasText('Type is required.', 'Type validation error renders');
|
||||||
|
assert
|
||||||
|
.dom('[data-test-field="commonName"] [data-test-inline-alert]')
|
||||||
|
.hasText('Common name is required.', 'Common name validation error renders');
|
||||||
|
assert.dom('[data-test-alert]').hasText('There are 2 errors with this form.', 'Alert renders');
|
||||||
|
|
||||||
|
await click('[data-test-cancel]');
|
||||||
|
});
|
||||||
|
});
|
|
@ -0,0 +1,97 @@
|
||||||
|
import { module, test } from 'qunit';
|
||||||
|
import { setupRenderingTest } from 'vault/tests/helpers';
|
||||||
|
import { click, render, settled } from '@ember/test-helpers';
|
||||||
|
import { hbs } from 'ember-cli-htmlbars';
|
||||||
|
import { setupEngine } from 'ember-engines/test-support';
|
||||||
|
|
||||||
|
const selectors = {
|
||||||
|
keys: '[data-test-toggle-group="Key parameters"]',
|
||||||
|
sanOptions: '[data-test-toggle-group="Subject Alternative Name (SAN) Options"]',
|
||||||
|
subjectFields: '[data-test-toggle-group="Additional subject fields"]',
|
||||||
|
};
|
||||||
|
|
||||||
|
module('Integration | Component | PkiGenerateToggleGroups', function (hooks) {
|
||||||
|
setupRenderingTest(hooks);
|
||||||
|
setupEngine(hooks, 'pki');
|
||||||
|
|
||||||
|
hooks.beforeEach(async function () {
|
||||||
|
this.model = this.owner
|
||||||
|
.lookup('service:store')
|
||||||
|
.createRecord('pki/action', { actionType: 'generate-root' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('it should render key parameters', async function (assert) {
|
||||||
|
await render(hbs`<PkiGenerateToggleGroups @model={{this.model}} />`, { owner: this.engine });
|
||||||
|
|
||||||
|
assert.dom(selectors.keys).hasText('Key parameters', 'Key parameters group renders');
|
||||||
|
|
||||||
|
await click(selectors.keys);
|
||||||
|
|
||||||
|
assert
|
||||||
|
.dom('[data-test-toggle-group-description]')
|
||||||
|
.hasText(
|
||||||
|
'Please choose a type to see key parameter options.',
|
||||||
|
'Placeholder renders for key params when type is not selected'
|
||||||
|
);
|
||||||
|
const fields = {
|
||||||
|
exported: ['keyName', 'keyType', 'keyBits'],
|
||||||
|
internal: ['keyName', 'keyType', 'keyBits'],
|
||||||
|
existing: ['keyRef'],
|
||||||
|
kms: ['keyName', 'managedKeyName', 'managedKeyId'],
|
||||||
|
};
|
||||||
|
for (const type in fields) {
|
||||||
|
this.model.type = type;
|
||||||
|
await settled();
|
||||||
|
assert
|
||||||
|
.dom('[data-test-field]')
|
||||||
|
.exists({ count: fields[type].length }, `Correct number of fields render for ${type} type`);
|
||||||
|
fields[type].forEach((key) => {
|
||||||
|
assert.dom(`[data-test-input="${key}"]`).exists(`${key} input renders for ${type} type`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('it should render SAN options', async function (assert) {
|
||||||
|
await render(hbs`<PkiGenerateToggleGroups @model={{this.model}} />`, { owner: this.engine });
|
||||||
|
|
||||||
|
assert
|
||||||
|
.dom(selectors.sanOptions)
|
||||||
|
.hasText('Subject Alternative Name (SAN) Options', 'SAN options group renders');
|
||||||
|
|
||||||
|
await click(selectors.sanOptions);
|
||||||
|
|
||||||
|
const fields = ['excludeCnFromSans', 'serialNumber', 'altNames', 'ipSans', 'uriSans', 'otherSans'];
|
||||||
|
assert.dom('[data-test-field]').exists({ count: 6 }, `Correct number of fields render`);
|
||||||
|
fields.forEach((key) => {
|
||||||
|
assert.dom(`[data-test-input="${key}"]`).exists(`${key} input renders for generate-root actionType`);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.model.actionType = 'generate-csr';
|
||||||
|
await settled();
|
||||||
|
|
||||||
|
assert
|
||||||
|
.dom('[data-test-field]')
|
||||||
|
.exists({ count: 4 }, 'Correct number of fields render for generate-csr actionType');
|
||||||
|
|
||||||
|
assert
|
||||||
|
.dom('[data-test-input="excludeCnFromSans"]')
|
||||||
|
.doesNotExist('excludeCnFromSans field hidden for generate-csr actionType');
|
||||||
|
assert
|
||||||
|
.dom('[data-test-input="serialNumber"]')
|
||||||
|
.doesNotExist('serialNumber field hidden for generate-csr actionType');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('it should render additional subject fields', async function (assert) {
|
||||||
|
await render(hbs`<PkiGenerateToggleGroups @model={{this.model}} />`, { owner: this.engine });
|
||||||
|
|
||||||
|
assert.dom(selectors.subjectFields).hasText('Additional subject fields', 'SAN options group renders');
|
||||||
|
|
||||||
|
await click(selectors.subjectFields);
|
||||||
|
|
||||||
|
const fields = ['ou', 'organization', 'country', 'locality', 'province', 'streetAddress', 'postalCode'];
|
||||||
|
assert.dom('[data-test-field]').exists({ count: fields.length }, 'Correct number of fields render');
|
||||||
|
fields.forEach((key) => {
|
||||||
|
assert.dom(`[data-test-input="${key}"]`).exists(`${key} input renders`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -20,6 +20,8 @@
|
||||||
"vault/tests/*": ["tests/*"],
|
"vault/tests/*": ["tests/*"],
|
||||||
"vault/mirage/*": ["mirage/*"],
|
"vault/mirage/*": ["mirage/*"],
|
||||||
"vault/*": [
|
"vault/*": [
|
||||||
|
"types/*",
|
||||||
|
"types/vault/*",
|
||||||
"app/*",
|
"app/*",
|
||||||
"lib/core/app/*",
|
"lib/core/app/*",
|
||||||
"lib/css/app/*",
|
"lib/css/app/*",
|
||||||
|
|
|
@ -8,3 +8,18 @@ export interface FormField {
|
||||||
export interface FormFieldGroups {
|
export interface FormFieldGroups {
|
||||||
[key: string]: Array<FormField>;
|
[key: string]: Array<FormField>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface FormFieldGroupOptions {
|
||||||
|
[key: string]: Array<string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ModelValidation {
|
||||||
|
isValid: boolean;
|
||||||
|
state: {
|
||||||
|
[key: string]: {
|
||||||
|
isValid: boolean;
|
||||||
|
errors: Array<string>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
invalidFormMessage: string;
|
||||||
|
}
|
||||||
|
|
|
@ -0,0 +1,21 @@
|
||||||
|
import ComputedProperty from '@ember/object/computed';
|
||||||
|
import Model from '@ember-data/model';
|
||||||
|
|
||||||
|
interface CapabilitiesModel extends Model {
|
||||||
|
path: string;
|
||||||
|
capabilities: Array<string>;
|
||||||
|
canSudo: ComputedProperty<boolean | undefined>;
|
||||||
|
canRead: ComputedProperty<boolean | undefined>;
|
||||||
|
canCreate: ComputedProperty<boolean | undefined>;
|
||||||
|
canUpdate: ComputedProperty<boolean | undefined>;
|
||||||
|
canDelete: ComputedProperty<boolean | undefined>;
|
||||||
|
canList: ComputedProperty<boolean | undefined>;
|
||||||
|
// these don't seem to be used anywhere
|
||||||
|
// inferring type from key name
|
||||||
|
allowedParameters: Array<string>;
|
||||||
|
deniedParameters: Array<string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default CapabilitiesModel;
|
||||||
|
export const SUDO_PATHS: string[];
|
||||||
|
export const SUDO_PATH_PREFIXES: string[];
|
|
@ -1,15 +1,20 @@
|
||||||
import Model from '@ember-data/model';
|
import Model from '@ember-data/model';
|
||||||
|
import { FormField, ModelValidations } from 'vault/app-types';
|
||||||
|
import CapabilitiesModel from '../capabilities';
|
||||||
|
|
||||||
export default class PkiActionModel extends Model {
|
export default class PkiActionModel extends Model {
|
||||||
secretMountPath: unknown;
|
secretMountPath: unknown;
|
||||||
pemBundle: string;
|
pemBundle: string;
|
||||||
type: string;
|
type: string;
|
||||||
|
actionType: string | null;
|
||||||
get backend(): string;
|
get backend(): string;
|
||||||
// apiPaths for capabilities
|
// apiPaths for capabilities
|
||||||
importBundlePath: string;
|
importBundlePath: Promise<CapabilitiesModel>;
|
||||||
generateIssuerRootPath: string;
|
generateIssuerRootPath: Promise<CapabilitiesModel>;
|
||||||
generateIssuerCsrPath: string;
|
generateIssuerCsrPath: Promise<CapabilitiesModel>;
|
||||||
crossSignPath: string;
|
crossSignPath: string;
|
||||||
|
allFields: Array<FormField>;
|
||||||
|
validate(): ModelValidations;
|
||||||
// Capabilities
|
// Capabilities
|
||||||
get canImportBundle(): boolean;
|
get canImportBundle(): boolean;
|
||||||
get canGenerateIssuerRoot(): boolean;
|
get canGenerateIssuerRoot(): boolean;
|
||||||
|
|
|
@ -0,0 +1,6 @@
|
||||||
|
import Model from '@ember-data/model';
|
||||||
|
import { FormField, FormFieldGroups, FormFieldGroupOptions } from 'vault/app-types';
|
||||||
|
|
||||||
|
export default function _default(modelClass: Model, fieldGroups: FormFieldGroupOptions): FormFieldGroups;
|
||||||
|
|
||||||
|
export function expandAttributeMeta(modelClass: Model, attributeNames: Array<string>): Array<FormField>;
|
Loading…
Reference in New Issue