diff --git a/ui/app/adapters/pki/pki-issuer-engine.js b/ui/app/adapters/pki/pki-issuer-engine.js index 7f3e21c1ae..af3bd0f037 100644 --- a/ui/app/adapters/pki/pki-issuer-engine.js +++ b/ui/app/adapters/pki/pki-issuer-engine.js @@ -20,9 +20,8 @@ export default class PkiIssuerEngineAdapter extends ApplicationAdapter { return url; } - async query(store, type, query) { + query(store, type, query) { const { backend, id } = query; - let response = await this.ajax(this.urlForQuery(backend, id), 'GET', this.optionsForQuery(id)); - return response; + return this.ajax(this.urlForQuery(backend, id), 'GET', this.optionsForQuery(id)); } } diff --git a/ui/app/adapters/pki/pki-key-engine.js b/ui/app/adapters/pki/pki-key-engine.js index a5e4665f06..98982ca202 100644 --- a/ui/app/adapters/pki/pki-key-engine.js +++ b/ui/app/adapters/pki/pki-key-engine.js @@ -20,9 +20,8 @@ export default class PkiKeyEngineAdapter extends ApplicationAdapter { return url; } - async query(store, type, query) { + query(store, type, query) { const { backend, id } = query; - let response = await this.ajax(this.urlForQuery(backend, id), 'GET', this.optionsForQuery(id)); - return response; + return this.ajax(this.urlForQuery(backend, id), 'GET', this.optionsForQuery(id)); } } diff --git a/ui/app/adapters/pki/pki-role-engine.js b/ui/app/adapters/pki/pki-role-engine.js index c756445af9..090ef2254b 100644 --- a/ui/app/adapters/pki/pki-role-engine.js +++ b/ui/app/adapters/pki/pki-role-engine.js @@ -1,3 +1,52 @@ -import PkiRoleAdapter from './pki-role'; +import ApplicationAdapter from '../application'; +import { assign } from '@ember/polyfills'; +import { encodePath } from 'vault/utils/path-encoding-helpers'; -export default class PkiRoleEngineAdapter extends PkiRoleAdapter {} +export default class PkiRoleEngineAdapter extends ApplicationAdapter { + namespace = 'v1'; + + _urlForRole(backend, id) { + let url = `${this.buildURL()}/${encodePath(backend)}/roles`; + if (id) { + url = url + '/' + encodePath(id); + } + return url; + } + _optionsForQuery(id) { + let data = {}; + if (!id) { + data['list'] = true; + } + return { data }; + } + + createRecord(store, type, snapshot) { + let name = snapshot.attr('name'); + let url = this._urlForRole(snapshot.record.backend, name); + + return this.ajax(url, 'POST', { data: this.serialize(snapshot) }).then(() => { + return { + id: name, + name, + backend: snapshot.record.backend, + }; + }); + } + + fetchByQuery(store, query) { + const { id, backend } = query; + return this.ajax(this._urlForRole(backend, id), 'GET', this._optionsForQuery(id)).then((resp) => { + const data = { + id, + name: id, + backend, + }; + + return assign({}, resp, data); + }); + } + + query(store, type, query) { + return this.fetchByQuery(store, query); + } +} diff --git a/ui/app/models/pki/pki-role-engine.js b/ui/app/models/pki/pki-role-engine.js index 05ad0c8fd9..46cd52df32 100644 --- a/ui/app/models/pki/pki-role-engine.js +++ b/ui/app/models/pki/pki-role-engine.js @@ -6,25 +6,77 @@ import { withModelValidations } from 'vault/decorators/model-validations'; import fieldToAttrs from 'vault/utils/field-to-attrs'; const validations = { - name: [ - { type: 'presence', message: 'Name is required.' }, - { - type: 'containsWhiteSpace', - message: 'Name cannot contain whitespace.', - }, - ], + name: [{ type: 'presence', message: 'Name is required.' }], }; @withModelValidations(validations) export default class PkiRoleEngineModel extends Model { @attr('string', { readOnly: true }) backend; + @attr('string', { label: 'Role name', - fieldValue: 'id', - readOnly: true, + fieldValue: 'name', }) name; + @attr('string', { + label: 'Issuer reference', + defaultValue: 'default', + subText: + 'Specifies the issuer that will be used to create certificates with this role. To find this, run [command]. By default, we will use the mounts default issuer.', + }) + issuerRef; + + @attr({ + label: 'Not valid after', + subText: + 'The time after which this certificate will no longer be valid. This can be a TTL (a range of time from now) or a specific date. If no TTL is set, the system uses "default" or the value of max_ttl, whichever is shorter. Alternatively, you can set the not_after date below.', + editType: 'yield', + }) + customTtl; + + @attr({ + label: 'Backdate validity', + helperTextEnabled: + 'Also called the not_before_duration property. Allows certificates to be valid for a certain time period before now. This is useful to correct clock misalignment on various systems when setting up your CA.', + editType: 'ttl', + hideToggle: true, + }) + notBeforeDuration; + + @attr({ + label: 'Max TTL', + helperTextDisabled: + 'The maximum Time-To-Live of certificates generated by this role. If not set, the system max lease TTL will be used.', + editType: 'ttl', + }) + maxTtl; + + @attr('boolean', { + label: 'Generate lease with certificate', + subText: + 'Specifies if certificates issued/signed against this role will have Vault leases attached to them.', + editType: 'boolean', + docLink: '/api-docs/secret/pki#create-update-role', + }) + generateLease; + + @attr('boolean', { + label: 'Do not store certificates in storage backend', + subText: + 'This can improve performance when issuing large numbers of certificates. However, certificates issued in this way cannot be enumerated or revoked.', + editType: 'boolean', + docLink: '/api-docs/secret/pki#create-update-role', + }) + noStore; + + @attr('boolean', { + label: 'Basic constraints valid for non CA.', + subText: 'Mark Basic Constraints valid when issuing non-CA certificates.', + editType: 'boolean', + }) + addBasicConstraints; + // must be a getter so it can be added to the prototype needed in the pathHelp service on the line here: if (newModel.merged || modelProto.useOpenAPI !== true) { get useOpenAPI() { return true; @@ -72,7 +124,18 @@ export default class PkiRoleEngineModel extends Model { get fieldGroups() { if (!this._fieldToAttrsGroups) { this._fieldToAttrsGroups = fieldToAttrs(this, [ - { default: ['name'] }, + { + default: [ + 'name', + 'issuerRef', + 'customTtl', + 'notBeforeDuration', + 'maxTtl', + 'generateLease', + 'noStore', + 'addBasicConstraints', + ], + }, { 'Domain handling': [ 'allowedDomains', diff --git a/ui/app/styles/core/helpers.scss b/ui/app/styles/core/helpers.scss index 88b7410887..92ff533982 100644 --- a/ui/app/styles/core/helpers.scss +++ b/ui/app/styles/core/helpers.scss @@ -213,6 +213,9 @@ .has-top-margin-xxl { margin-top: $spacing-xxl; } +.has-top-margin-negative-s { + margin-top: (-1 * $spacing-s); +} .has-left-margin-xxs { margin-left: $spacing-xxs; } diff --git a/ui/app/templates/components/form-field-groups-loop.hbs b/ui/lib/core/addon/components/form-field-groups-loop.hbs similarity index 59% rename from ui/app/templates/components/form-field-groups-loop.hbs rename to ui/lib/core/addon/components/form-field-groups-loop.hbs index fdb5c9b9f5..b6c02eaed5 100644 --- a/ui/app/templates/components/form-field-groups-loop.hbs +++ b/ui/lib/core/addon/components/form-field-groups-loop.hbs @@ -4,7 +4,15 @@ {{#each fields as |attr|}} {{! template-lint-configure simple-unless "warn" }} {{#unless (and (not-eq @mode "create") (eq attr.name "name"))}} - + + {{yield attr}} + {{/unless}} {{/each}} {{else}} @@ -20,7 +28,15 @@ {{#if (get @model prop)}}
{{#each fields as |attr|}} - + + {{yield attr}} + {{/each}}
{{/if}} diff --git a/ui/lib/core/addon/components/form-field.hbs b/ui/lib/core/addon/components/form-field.hbs index 2a42b76b1a..bfe3d9cf82 100644 --- a/ui/lib/core/addon/components/form-field.hbs +++ b/ui/lib/core/addon/components/form-field.hbs @@ -13,7 +13,7 @@ @@ -321,7 +321,6 @@ onchange={{this.onChangeWithEvent}} data-test-input={{@attr.name}} /> - {{#if @attr.options.subText}} -

{{@attr.options.subText}}

+

+ {{@attr.options.subText}} + {{#if @attr.options.docLink}} + + Learn more here. + + {{/if}} +

{{/if}} {{else if (eq @attr.type "object")}} @@ -339,5 +345,7 @@ @valueUpdated={{fn this.codemirrorUpdated false}} @helpText={{@attr.options.helpText}} /> + {{else if (eq @attr.options.editType "yield")}} + {{yield}} {{/if}} \ No newline at end of file diff --git a/ui/lib/core/addon/components/form-field.js b/ui/lib/core/addon/components/form-field.js index 81408e49c6..aa7b754dd7 100644 --- a/ui/lib/core/addon/components/form-field.js +++ b/ui/lib/core/addon/components/form-field.js @@ -21,7 +21,7 @@ import { dasherize } from 'vault/helpers/dasherize'; * label: "Foo", // custom label to be shown, otherwise attr.name will be displayed * defaultValue: "", // default value to display if model value is not present * fieldValue: "bar", // used for value lookup on model over attr.name - * editType: "ttl", type of field to use -- example boolean, searchSelect, etc. + * editType: "ttl", type of field to use. List of editTypes:boolean, file, json, kv, optionalText, mountAccessor, password, radio, regex, searchSelect, stringArray,textarea, ttl, yield. * helpText: "This will be in a tooltip", * readOnly: true * }, @@ -58,7 +58,7 @@ export default class FormFieldComponent extends Component { return this.args.disabled || false; } get showHelpText() { - return this.args.showHelpText || true; + return this.args.showHelpText === false ? false : true; } get subText() { return this.args.subText || ''; diff --git a/ui/lib/core/addon/components/radio-select-ttl-or-string.hbs b/ui/lib/core/addon/components/radio-select-ttl-or-string.hbs new file mode 100644 index 0000000000..60ff240bc4 --- /dev/null +++ b/ui/lib/core/addon/components/radio-select-ttl-or-string.hbs @@ -0,0 +1,48 @@ +
+ + +
+
+ + +
\ No newline at end of file diff --git a/ui/lib/core/addon/components/radio-select-ttl-or-string.js b/ui/lib/core/addon/components/radio-select-ttl-or-string.js new file mode 100644 index 0000000000..b9d1e29702 --- /dev/null +++ b/ui/lib/core/addon/components/radio-select-ttl-or-string.js @@ -0,0 +1,47 @@ +import Component from '@glimmer/component'; +import { action } from '@ember/object'; +import { tracked } from '@glimmer/tracking'; + +/** + * @module RadioSelectTtlOrString + * `RadioSelectTtlOrString` components are yielded out within the formField component when the editType on the model is yield. + * The component is two radio buttons, where the first option is a TTL, and the second option is an input field without a title. + * This component is used in the PKI engine inside various forms. + * + * @example + * ```js + * {{#each @model.fields as |attr|}} + * + * {{/each}} + * ``` + * @param {Model} model - Ember Data model that `attr` is defined on. + * @param {Object} attr - Usually derived from ember model `attributes` lookup, and all members of `attr.options` are optional. + */ + +export default class RadioSelectTtlOrString extends Component { + @tracked groupValue = 'ttl'; + @tracked ttlTime; + @tracked notAfter; + + @action onRadioButtonChange(selection) { + this.groupValue = selection; + // Clear the previous selection if they have clicked the other radio button. + if (selection === 'specificDate') { + this.args.model.set('ttl', ''); + this.ttlTime = ''; //clear out the form field + } + if (selection === 'tll') { + this.args.model.set('notAfter', ''); + this.notAfter = ''; //clear out the form field + } + } + + @action setAndBroadcastTtl(value) { + let valueToSet = value.enabled === true ? `${value.seconds}s` : 0; + this.args.model.set('ttl', `${valueToSet}`); + } + + @action setAndBroadcastInput(event) { + this.args.model.set('notAfter', event.target.value); + } +} diff --git a/ui/lib/core/app/components/form-field-groups-loop.js b/ui/lib/core/app/components/form-field-groups-loop.js new file mode 100644 index 0000000000..a3935ebc5f --- /dev/null +++ b/ui/lib/core/app/components/form-field-groups-loop.js @@ -0,0 +1 @@ +export { default } from 'core/components/form-field-groups-loop'; diff --git a/ui/lib/core/app/components/radio-select-ttl-or-string.js b/ui/lib/core/app/components/radio-select-ttl-or-string.js new file mode 100644 index 0000000000..bc3f167f65 --- /dev/null +++ b/ui/lib/core/app/components/radio-select-ttl-or-string.js @@ -0,0 +1 @@ +export { default } from 'core/components/radio-select-ttl-or-string'; diff --git a/ui/lib/pki/addon/components/pki-role-form.hbs b/ui/lib/pki/addon/components/pki-role-form.hbs new file mode 100644 index 0000000000..87f51ad1dd --- /dev/null +++ b/ui/lib/pki/addon/components/pki-role-form.hbs @@ -0,0 +1,60 @@ + + + +
  • + + / + + + {{@model.backend}} + +
  • +
    +
    + +

    + {{#if @model.isNew}} + Create a PKI role + {{else}} + Edit a + {{@model.id}} + {{/if}} +

    +
    +
    + +
    +
    + + {{! ARG TODO write a test for namespace reminder }} + + + + +
    +
    + + + {{#if this.modelValidations.targets.errors}} + + {{/if}} + {{#if this.invalidFormAlert}} +
    + +
    + {{/if}} +
    +
    \ No newline at end of file diff --git a/ui/lib/pki/addon/components/pki-role-form.js b/ui/lib/pki/addon/components/pki-role-form.js new file mode 100644 index 0000000000..c0bbf5b81c --- /dev/null +++ b/ui/lib/pki/addon/components/pki-role-form.js @@ -0,0 +1,56 @@ +import Component from '@glimmer/component'; +import { action } from '@ember/object'; +import { inject as service } from '@ember/service'; +import { task } from 'ember-concurrency'; +import { tracked } from '@glimmer/tracking'; + +/** + * @module PkiRoleForm + * PkiRoleForm components are used to create and update PKI roles. + * + * @example + * ```js + * + * ``` + * @callback onCancel + * @callback onSave + * @param {Object} model - Pki-role-engine model. + * @param {onCancel} onCancel - Callback triggered when cancel button is clicked. + * @param {onSave} onSave - Callback triggered on save success. + */ + +export default class PkiRoleForm extends Component { + @service store; + @service flashMessages; + + @tracked errorBanner; + @tracked invalidFormAlert; + @tracked modelValidations; + + @task + *save(event) { + event.preventDefault(); + try { + const { isValid, state, invalidFormMessage } = this.args.model.validate(); + this.modelValidations = isValid ? null : state; + this.invalidFormAlert = invalidFormMessage; + if (isValid) { + const { isNew, name } = this.args.model; + yield this.args.model.save(); + this.flashMessages.success(`Successfully ${isNew ? 'created' : 'updated'} the role ${name}.`); + this.args.onSave(); + } + } catch (error) { + const message = error.errors ? error.errors.join('. ') : error.message; + this.errorBanner = message; + this.invalidFormAlert = 'There was an error submitting this form.'; + } + } + + @action + cancel() { + const method = this.args.model.isNew ? 'unloadRecord' : 'rollbackAttributes'; + this.args.model[method](); + this.args.onCancel(); + } +} diff --git a/ui/lib/pki/addon/routes/roles.js b/ui/lib/pki/addon/routes/roles.js deleted file mode 100644 index b135ba450b..0000000000 --- a/ui/lib/pki/addon/routes/roles.js +++ /dev/null @@ -1,3 +0,0 @@ -import Route from '@ember/routing/route'; - -export default class PkiRolesRoute extends Route {} diff --git a/ui/lib/pki/addon/routes/roles/create.js b/ui/lib/pki/addon/routes/roles/create.js new file mode 100644 index 0000000000..5092f4e976 --- /dev/null +++ b/ui/lib/pki/addon/routes/roles/create.js @@ -0,0 +1,18 @@ +import Route from '@ember/routing/route'; +import { inject as service } from '@ember/service'; + +export default class PkiRolesCreateRoute extends Route { + @service store; + @service secretMountPath; + @service pathHelp; + + beforeModel() { + return this.pathHelp.getNewModel('pki/pki-role-engine', 'pki'); + } + + model() { + return this.store.createRecord('pki/pki-role-engine', { + backend: this.secretMountPath.currentPath, + }); + } +} diff --git a/ui/lib/pki/addon/routes/roles/index.js b/ui/lib/pki/addon/routes/roles/index.js index 90732db9a6..35c5e0aa06 100644 --- a/ui/lib/pki/addon/routes/roles/index.js +++ b/ui/lib/pki/addon/routes/roles/index.js @@ -6,12 +6,17 @@ export default class RolesIndexRoute extends Route { @service secretMountPath; @service pathHelp; - model() { - // the pathHelp service is needed for adding openAPI to the model - this.pathHelp.getNewModel('pki/pki-role-engine', 'pki'); + beforeModel() { + // Must call this promise before the model hook otherwise it doesn't add OpenApi to record. + return this.pathHelp.getNewModel('pki/pki-role-engine', 'pki'); + } + model() { return this.store .query('pki/pki-role-engine', { backend: this.secretMountPath.currentPath }) + .then((roleModel) => { + return { roleModel, parentModel: this.modelFor('roles') }; + }) .catch((err) => { if (err.httpStatus === 404) { return []; diff --git a/ui/lib/pki/addon/templates/roles.hbs b/ui/lib/pki/addon/templates/roles.hbs deleted file mode 100644 index c1651378d9..0000000000 --- a/ui/lib/pki/addon/templates/roles.hbs +++ /dev/null @@ -1,11 +0,0 @@ - -{{outlet}} \ No newline at end of file diff --git a/ui/lib/pki/addon/templates/roles/create.hbs b/ui/lib/pki/addon/templates/roles/create.hbs new file mode 100644 index 0000000000..6f6978786e --- /dev/null +++ b/ui/lib/pki/addon/templates/roles/create.hbs @@ -0,0 +1,5 @@ + \ No newline at end of file diff --git a/ui/lib/pki/addon/templates/roles/index.hbs b/ui/lib/pki/addon/templates/roles/index.hbs index ef641c5f09..a463a05189 100644 --- a/ui/lib/pki/addon/templates/roles/index.hbs +++ b/ui/lib/pki/addon/templates/roles/index.hbs @@ -1,3 +1,13 @@ + @@ -6,8 +16,8 @@ -{{#if (gt this.model.length 0)}} - {{#each this.model as |pkiRole|}} +{{#if (gt this.model.roleModel.length 0)}} + {{#each this.model.roleModel as |pkiRole|}}