diff --git a/ui/app/adapters/pki/action.js b/ui/app/adapters/pki/action.js new file mode 100644 index 000000000..1e84a92b0 --- /dev/null +++ b/ui/app/adapters/pki/action.js @@ -0,0 +1,41 @@ +import { assert } from '@ember/debug'; +import { encodePath } from 'vault/utils/path-encoding-helpers'; +import ApplicationAdapter from '../application'; + +export default class PkiActionAdapter extends ApplicationAdapter { + namespace = 'v1'; + + urlForCreateRecord(modelName, snapshot) { + const { backend, type } = snapshot.record; + const { actionType, useIssuer } = snapshot.adapterOptions; + if (!backend || !actionType) { + throw new Error('URL for create record is missing required attributes'); + } + const baseUrl = `${this.buildURL()}/${encodePath(backend)}`; + switch (actionType) { + case 'import': + return useIssuer ? `${baseUrl}/issuers/import/bundle` : `${baseUrl}/config/ca`; + case 'generate-root': + return useIssuer ? `${baseUrl}/issuers/generate/root/${type}` : `${baseUrl}/root/generate/${type}`; + case 'generate-csr': + return useIssuer + ? `${baseUrl}/issuers/generate/intermediate/${type}` + : `${baseUrl}/intermediate/generate/${type}`; + default: + assert('actionType must be one of import, generate-root, or generate-csr'); + } + } + + createRecord(store, type, snapshot) { + const serializer = store.serializerFor(type.modelName); + const url = this.urlForCreateRecord(type.modelName, snapshot); + // Send actionType as serializer requestType so that we serialize data based on the endpoint + const data = serializer.serialize(snapshot, snapshot.adapterOptions.actionType); + return this.ajax(url, 'POST', { data }).then((result) => ({ + // pki/action endpoints don't correspond with a single specific entity, + // so in ember-data we'll map it to the request ID + id: result.request_id, + ...result, + })); + } +} diff --git a/ui/app/adapters/pki/config.js b/ui/app/adapters/pki/config.js deleted file mode 100644 index d36a570e8..000000000 --- a/ui/app/adapters/pki/config.js +++ /dev/null @@ -1,28 +0,0 @@ -import { assert } from '@ember/debug'; -import { encodePath } from 'vault/utils/path-encoding-helpers'; -import ApplicationAdapter from '../application'; - -export default class PkiConfigAdapter extends ApplicationAdapter { - namespace = 'v1'; - - urlForCreateRecord(modelName, snapshot) { - const { backend, type } = snapshot.record; - const { formType, useIssuer } = snapshot.adapterOptions; - if (!backend || !formType) { - throw new Error('URL for create record is missing required attributes'); - } - const baseUrl = `${this.buildURL()}/${encodePath(backend)}`; - switch (formType) { - case 'import': - return useIssuer ? `${baseUrl}/issuers/import/bundle` : `${baseUrl}/config/ca`; - case 'generate-root': - return useIssuer ? `${baseUrl}/issuers/generate/root/${type}` : `${baseUrl}/root/generate/${type}`; - case 'generate-csr': - return useIssuer - ? `${baseUrl}/issuers/generate/intermediate/${type}` - : `${baseUrl}/intermediate/generate/${type}`; - default: - assert('formType must be one of import, generate-root, or generate-csr'); - } - } -} diff --git a/ui/app/decorators/model-form-fields.js b/ui/app/decorators/model-form-fields.js index 2e2963504..d85b4528c 100644 --- a/ui/app/decorators/model-form-fields.js +++ b/ui/app/decorators/model-form-fields.js @@ -22,17 +22,17 @@ export function withFormFields(propertyNames, groupPropertyNames) { return class ModelFormFields extends SuperClass { constructor() { super(...arguments); - if (!Array.isArray(propertyNames) && !Array.isArray(groupPropertyNames)) { - throw new Error( - 'Array of property names and/or array of field groups are required when using withFormFields model decorator' - ); - } if (propertyNames) { this.formFields = expandAttributeMeta(this, propertyNames); } if (groupPropertyNames) { this.formFieldGroups = fieldToAttrs(this, groupPropertyNames); } + const allFields = []; + this.eachAttribute(function (key) { + allFields.push(key); + }); + this.allFields = expandAttributeMeta(this, allFields); } }; }; diff --git a/ui/app/models/pki/action.js b/ui/app/models/pki/action.js new file mode 100644 index 000000000..499db2f76 --- /dev/null +++ b/ui/app/models/pki/action.js @@ -0,0 +1,181 @@ +import Model, { attr } from '@ember-data/model'; +import { inject as service } from '@ember/service'; +import lazyCapabilities, { apiPath } from 'vault/macros/lazy-capabilities'; +import { withFormFields } from 'vault/decorators/model-form-fields'; +import { withModelValidations } from 'vault/decorators/model-validations'; + +const validations = { + type: [{ type: 'presence', message: 'Type is required.' }], + commonName: [{ type: 'presence', message: 'Common name is required.' }], + issuerName: [ + { + validator(model) { + if (model.issuerName === 'default') return false; + return true; + }, + message: 'Issuer name must be unique across all issuers and not be the reserved value default.', + }, + ], +}; + +@withModelValidations(validations) +@withFormFields() +export default class PkiActionModel extends Model { + @service secretMountPath; + + /* actionType import */ + @attr('string') pemBundle; + + /* actionType generate-root */ + @attr('string', { + possibleValues: ['exported', 'internal', 'existing', 'kms'], + noDefault: true, + }) + type; + + @attr('string') issuerName; // REQUIRED, cannot be "default" + + @attr('string') keyName; // cannot be "default" + + @attr('string', { + defaultValue: 'default', + label: 'Key reference', + }) + keyRef; // type=existing only + + @attr('string') commonName; // REQUIRED + + @attr('string', { + label: 'Subject Alternative Names (SANs)', + }) + altNames; // comma sep strings + + @attr('string', { + label: 'IP Subject Alternative Names (IP SANs)', + }) + ipSans; + + @attr('string', { + label: 'URI Subject Alternative Names (URI SANs)', + }) + uriSans; + + @attr('string', { + label: 'Other SANs', + }) + otherSans; + + @attr('string', { + defaultValue: 'pem', + possibleValues: ['pem', 'der', 'pem_bundle'], + }) + format; + + @attr('string', { + defaultValue: 'der', + possibleValues: ['der', 'pkcs8'], + }) + privateKeyFormat; + + @attr('string', { + defaultValue: 'rsa', + possibleValues: ['rsa', 'ed25519', 'ec'], + }) + keyType; + + @attr('string', { + defaultValue: '0', + // options management happens in pki-key-parameters + }) + keyBits; + + @attr('number', { + defaultValue: -1, + }) + maxPathLength; + + @attr('boolean', { + label: 'Exclude common name from SANs', + subText: + 'If checked, the common name will not be included in DNS or Email Subject Alternate Names. This is useful if the CN is a human-readable identifier, not a hostname or email address.', + defaultValue: false, + }) + excludeCnFromSans; + + @attr('string', { + label: 'Permitted DNS domains', + }) + permittedDnsDomains; + + @attr('string', { + label: 'Organizational Units (OU)', + }) + ou; + @attr('string') organization; + @attr('string') country; + @attr('string') locality; + @attr('string') province; + @attr('string') streetAddress; + @attr('string') postalCode; + + @attr('string', { + subText: "Specifies the requested Subject's named Serial Number value.", + }) + serialNumber; + + @attr({ + label: 'Backdate validity', + detailsLabel: 'Issued certificate backdating', + helperTextDisabled: 'Vault will use the default value, 30s', + 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', + defaultValue: '30s', + }) + notBeforeDuration; + + @attr('string') managedKeyName; + @attr('string', { + label: 'Managed key UUID', + }) + managedKeyId; + + @attr({ + label: 'Not valid after', + detailsLabel: 'Issued certificates expire 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.', + editType: 'yield', + }) + customTtl; + @attr('string') ttl; + @attr('date') notAfter; + + get backend() { + return this.secretMountPath.currentPath; + } + + // 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. + @lazyCapabilities(apiPath`${'backend'}/issuers/import/bundle`, 'backend') importBundlePath; + @lazyCapabilities(apiPath`${'backend'}/issuers/generate/root/${'type'}`, 'backend', 'type') + generateIssuerRootPath; + @lazyCapabilities(apiPath`${'backend'}/issuers/generate/intermediate/${'type'}`, 'backend', 'type') + generateIssuerCsrPath; + @lazyCapabilities(apiPath`${'backend'}/issuers/cross-sign`, 'backend') crossSignPath; + + get canImportBundle() { + return this.importBundlePath.get('canCreate') === true; + } + get canGenerateIssuerRoot() { + return this.generateIssuerRootPath.get('canCreate') === true; + } + get canGenerateIssuerIntermediate() { + return this.generateIssuerCsrPath.get('canCreate') === true; + } + get canCrossSign() { + return this.crossSignPath.get('canCreate') === true; + } +} diff --git a/ui/app/models/pki/config.js b/ui/app/models/pki/config.js deleted file mode 100644 index 7ff8d1dc3..000000000 --- a/ui/app/models/pki/config.js +++ /dev/null @@ -1,33 +0,0 @@ -import Model, { attr } from '@ember-data/model'; -import { inject as service } from '@ember/service'; -import lazyCapabilities, { apiPath } from 'vault/macros/lazy-capabilities'; - -export default class PkiConfigModel extends Model { - @service secretMountPath; - - @attr('string') pemBundle; - @attr('string') type; - - get backend() { - return this.secretMountPath.currentPath; - } - - @lazyCapabilities(apiPath`${'backend'}/issuers/import/bundle`, 'backend') importBundlePath; - @lazyCapabilities(apiPath`${'backend'}/issuers/generate/root/${'type'}`, 'backend', 'type') - generateIssuerRootPath; - @lazyCapabilities(apiPath`${'backend'}/issuers/generate/intermediate/${'type'}`, 'backend', 'type') - generateIssuerCsrPath; - - get canImportBundle() { - return this.importBundlePath.get('canCreate') !== false; - } - get canGenerateIssuerRoot() { - return this.generateIssuerRootPath.get('canCreate') !== false; - } - get canGenerateIssuerIntermediate() { - return this.generateIssuerCsrPath.get('canCreate') !== false; - } - get canCrossSign() { - return this.crossSignPath.get('canCreate') !== false; - } -} diff --git a/ui/app/serializers/pki/action.js b/ui/app/serializers/pki/action.js new file mode 100644 index 000000000..1575a3ce1 --- /dev/null +++ b/ui/app/serializers/pki/action.js @@ -0,0 +1,63 @@ +import ApplicationSerializer from '../application'; + +export default class PkiActionSerializer extends ApplicationSerializer { + attrs = { + customTtl: { serialize: false }, + type: { serialize: false }, + }; + + serialize(snapshot, requestType) { + const data = super.serialize(snapshot); + // requestType is a custom value specified from the pki/action adapter + const allowedPayloadAttributes = this._allowedParamsByType(requestType); + if (!allowedPayloadAttributes) return data; + + const payload = {}; + allowedPayloadAttributes.forEach((key) => { + if ('undefined' !== typeof data[key]) { + payload[key] = data[key]; + } + }); + return payload; + } + + _allowedParamsByType(actionType) { + switch (actionType) { + case 'import': + return ['pem_bundle']; + case 'generate-root': + return [ + 'alt_names', + 'common_name', + 'country', + 'exclude_cn_from_sans', + 'format', + 'ip_sans', + 'issuer_name', + 'key_bits', + 'key_name', + 'key_ref', + 'key_type', + 'locality', + 'managed_key_id', + 'managed_key_name', + 'max_path_length', + 'not_after', + 'not_before_duration', + 'organization', + 'other_sans', + 'ou', + 'permitted_dns_domains', + 'postal_code', + 'private_key_format', + 'province', + 'serial_number', + 'street_address', + 'type', + ]; + default: + // if type doesn't match, serialize all + return null; + } + } +} diff --git a/ui/app/serializers/pki/config.js b/ui/app/serializers/pki/config.js deleted file mode 100644 index 8571daa18..000000000 --- a/ui/app/serializers/pki/config.js +++ /dev/null @@ -1,3 +0,0 @@ -import ApplicationSerializer from '../application'; - -export default class PkiConfigSerializer extends ApplicationSerializer {} diff --git a/ui/lib/pki/README.md b/ui/lib/pki/README.md new file mode 100644 index 000000000..253d8ee7f --- /dev/null +++ b/ui/lib/pki/README.md @@ -0,0 +1,33 @@ +# Vault PKI + +Welcome to the Vault PKI (Ember) Engine! Below is an overview of PKI and resources for how to get started working within this engine. + +## About PKI + +> Public Key Infrastructure (PKI) is a system of processes, technologies, and policies that allows you to encrypt and sign data. (source: [digicert.com](https://www.digicert.com/what-is-pki)) + +The [Vault PKI Secrets Engine](https://developer.hashicorp.com/vault/api-docs/secret/pki) allows security engineers to [create a chain of PKI certificates](https://developer.hashicorp.com/vault/tutorials/secrets-management/pki-engine) much easier than they would with traditional workflows. + +## About the UI engine + +If you couldn't tell from the documentation above, PKI is _complex_. As such, the data doesn't map cleanly to a CRUD model and so the first thing you might notice is that the models and adapters for PKI (which [live in the main app](https://ember-engines.com/docs/addons#using-ember-data), not the engine) have some custom logic that differentiate it from most other secret engines. Below are the model + +### pki/key + +TBD + +### pki/role + +TBD + +### pki/issuer + +TBD + +### pki/certificate/\* + +TBD + +### pki/action + +TBD diff --git a/ui/lib/pki/addon/components/pki-ca-certificate-import.ts b/ui/lib/pki/addon/components/pki-ca-certificate-import.ts index 04dea4b68..10feb95e8 100644 --- a/ui/lib/pki/addon/components/pki-ca-certificate-import.ts +++ b/ui/lib/pki/addon/components/pki-ca-certificate-import.ts @@ -7,7 +7,7 @@ import { tracked } from '@glimmer/tracking'; import { waitFor } from '@ember/test-waiters'; import errorMessage from 'vault/utils/error-message'; import PkiBaseCertificateModel from 'vault/models/pki/certificate/base'; -import PkiConfigModel from 'vault/models/pki/config'; +import PkiActionModel from 'vault/models/pki/action'; /** * @module PkiCaCertificateImport @@ -27,7 +27,7 @@ import PkiConfigModel from 'vault/models/pki/config'; interface Args { onSave: CallableFunction; onCancel: CallableFunction; - model: PkiBaseCertificateModel | PkiConfigModel; + model: PkiBaseCertificateModel | PkiActionModel; adapterOptions: object | undefined; } diff --git a/ui/lib/pki/addon/components/pki-configure-form.hbs b/ui/lib/pki/addon/components/pki-configure-form.hbs index 35f44e28c..bbe7ae043 100644 --- a/ui/lib/pki/addon/components/pki-configure-form.hbs +++ b/ui/lib/pki/addon/components/pki-configure-form.hbs @@ -2,7 +2,7 @@
{{#each this.configTypes as |option|}}
-
- {{#if (eq this.formType "import")}} + {{#if (eq this.actionType "import")}} - {{else if (eq this.formType "generate-root")}} - POST /root/generate/:type ~or~ /issuers/generate/root/:type - {{else if (eq this.formType "generate-csr")}} + {{else if (eq this.actionType "generate-root")}} + + {{else if (eq this.actionType "generate-csr")}} POST /intermediate/generate/:type ~or~ /issuers/generate/intermediate/:type {{else}} @@ -45,9 +50,9 @@ - +
{{/if}} diff --git a/ui/lib/pki/addon/components/pki-configure-form.ts b/ui/lib/pki/addon/components/pki-configure-form.ts index 29a94a677..2930c1e09 100644 --- a/ui/lib/pki/addon/components/pki-configure-form.ts +++ b/ui/lib/pki/addon/components/pki-configure-form.ts @@ -4,11 +4,12 @@ import { inject as service } from '@ember/service'; import Store from '@ember-data/store'; import Router from '@ember/routing/router'; import FlashMessageService from 'vault/services/flash-messages'; -import PkiConfigModel from 'vault/models/pki/config'; import { tracked } from '@glimmer/tracking'; +import { action } from '@ember/object'; +import PkiActionModel from 'vault/models/pki/action'; interface Args { - config: PkiConfigModel; + config: PkiActionModel; } /** @@ -22,7 +23,7 @@ export default class PkiConfigureForm extends Component { @service declare readonly store: Store; @service declare readonly router: Router; @service declare readonly flashMessages: FlashMessageService; - @tracked formType = ''; + @tracked actionType = ''; get configTypes() { return [ @@ -56,7 +57,7 @@ export default class PkiConfigureForm extends Component { // 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.formType) { + switch (this.actionType) { case 'import': return config.canImportBundle; case 'generate-root': @@ -67,4 +68,9 @@ export default class PkiConfigureForm extends Component { return false; } } + + @action cancel() { + this.args.config.rollbackAttributes(); + this.router.transitionTo('vault.cluster.secrets.backend.pki.overview'); + } } diff --git a/ui/lib/pki/addon/components/pki-generate-root.hbs b/ui/lib/pki/addon/components/pki-generate-root.hbs new file mode 100644 index 000000000..ee464b2cb --- /dev/null +++ b/ui/lib/pki/addon/components/pki-generate-root.hbs @@ -0,0 +1,103 @@ +
+ + + {{#each this.defaultFields as |field|}} + {{#let (find-by "name" field @model.allFields) as |attr|}} + + {{#if (eq field "customTtl")}} + {{! customTtl attr has editType yield, which will render this }} + + {{/if}} + + {{/let}} + {{/each}} + + {{! togglable groups }} + {{#each-in this.groups as |group fields|}} + + {{#if (eq this.showGroup group)}} +
+ {{#if (eq group "Key parameters")}} +

+ {{#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. + Learn more about managed keys. + {{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}} +

+ {{#if this.keyParamFields}} + + {{/if}} + {{else}} +

+ {{#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}} +

+ {{#each fields as |fieldName|}} + {{#let (find-by "name" fieldName @model.allFields) as |attr|}} + + {{/let}} + {{/each}} + {{/if}} +
+ {{/if}} + {{/each-in}} + + {{!-- TODO: this section +
+ + {{! Updating this area of the form will require a secondary call to issuer/:issuer_ref/update once the initial request is complete }} +
--}} + +
+
+ + +
+ {{#if this.invalidFormAlert}} +
+ +
+ {{/if}} +
+ \ No newline at end of file diff --git a/ui/lib/pki/addon/components/pki-generate-root.js b/ui/lib/pki/addon/components/pki-generate-root.js new file mode 100644 index 000000000..f4280421f --- /dev/null +++ b/ui/lib/pki/addon/components/pki-generate-root.js @@ -0,0 +1,117 @@ +import { action } from '@ember/object'; +import { service } from '@ember/service'; +import Component from '@glimmer/component'; +import { tracked } from '@glimmer/tracking'; +import errorMessage from 'vault/utils/error-message'; + +/** + * @module PkiGenerateRoot + * PkiGenerateRoot shows only the fields valid for the generate root endpoint. + * This form handles the model save and rollback actions, and will call the passed + * onSave and onCancel args for transition (passed from parent). + * NOTE: this component is not TS because decorator-added parameters (eg validator and + * formFields) aren't recognized on the model. + * + * @example + * ```js + * + * ``` + * + * @param {Object} model - pki/action model. + * @callback onCancel - Callback triggered when cancel button is clicked, after model is unloaded + * @callback onSave - Callback triggered after model save success. + * @param {Object} adapterOptions - object passed as adapterOptions on the model.save method + */ +export default class PkiGenerateRootComponent extends Component { + @service flashMessages; + @tracked showGroup = null; + @tracked modelValidations = null; + @tracked errorBanner = ''; + @tracked invalidFormAlert = ''; + + @action + toggleGroup(group, isOpen) { + this.showGroup = isOpen ? group : null; + } + + get defaultFields() { + return [ + 'type', + 'commonName', + 'issuerName', + 'customTtl', + 'notBeforeDuration', + 'format', + 'permittedDnsDomains', + 'maxPathLength', + ]; + } + get keyParamFields() { + const { type } = this.args.model; + if (!type) return null; + let fields = ['keyName', 'keyType', 'keyBits']; + if (type === 'existing') { + fields = ['keyRef']; + } else if (type === 'kms') { + fields = ['keyName', 'managedKeyName', 'managedKeyId']; + } + return fields.map((fieldName) => { + return this.args.model.allFields.find((attr) => attr.name === fieldName); + }); + } + + @action cancel() { + // Generate root form will always have a new model + this.args.model.unloadRecord(); + 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 + checkFormValidity() { + if (this.args.model.validate) { + const { isValid, state, invalidFormMessage } = this.args.model.validate(); + this.modelValidations = state; + this.invalidFormAlert = invalidFormMessage; + return isValid; + } + return true; + } + + @action + async generateRoot(event) { + event.preventDefault(); + const continueSave = this.checkFormValidity(); + if (!continueSave) return; + try { + await this.args.model.save({ adapterOptions: this.args.adapterOptions }); + this.flashMessages.success('Successfully generated root.'); + this.args.onSave(); + } catch (e) { + this.errorBanner = errorMessage(e); + this.invalidFormAlert = 'There was a problem generating the root.'; + } + } +} diff --git a/ui/lib/pki/addon/components/pki-key-parameters.hbs b/ui/lib/pki/addon/components/pki-key-parameters.hbs index 36314901f..6ec310124 100644 --- a/ui/lib/pki/addon/components/pki-key-parameters.hbs +++ b/ui/lib/pki/addon/components/pki-key-parameters.hbs @@ -1,7 +1,12 @@ +{{yield}} {{#each @fields as |attr|}} {{#if (eq attr.name "keyBits")}} -
- +
+
diff --git a/ui/lib/pki/addon/components/pki-key-parameters.js b/ui/lib/pki/addon/components/pki-key-parameters.js index bd5f82cea..191763662 100644 --- a/ui/lib/pki/addon/components/pki-key-parameters.js +++ b/ui/lib/pki/addon/components/pki-key-parameters.js @@ -15,8 +15,8 @@ import { action } from '@ember/object'; // first value in array is the default bits for that key type const KEY_BITS_OPTIONS = { - rsa: ['2048', '3072', '4096'], - ec: ['256', '224', '384', '521'], + rsa: ['2048', '3072', '4096', '0'], + ec: ['256', '224', '384', '521', '0'], ed25519: ['0'], any: ['0'], }; diff --git a/ui/lib/pki/addon/routes/configuration/create.js b/ui/lib/pki/addon/routes/configuration/create.js index 30532c97e..af537e187 100644 --- a/ui/lib/pki/addon/routes/configuration/create.js +++ b/ui/lib/pki/addon/routes/configuration/create.js @@ -6,7 +6,7 @@ export default class PkiConfigurationCreateRoute extends Route { @service store; model() { - return this.store.createRecord('pki/config', {}); + return this.store.createRecord('pki/action', {}); } setupController(controller, resolvedModel) { diff --git a/ui/lib/pki/addon/routes/issuers/generate-root.js b/ui/lib/pki/addon/routes/issuers/generate-root.js index 6f96fa2bc..54ede077c 100644 --- a/ui/lib/pki/addon/routes/issuers/generate-root.js +++ b/ui/lib/pki/addon/routes/issuers/generate-root.js @@ -1,3 +1,21 @@ import Route from '@ember/routing/route'; +import { inject as service } from '@ember/service'; -export default class PkiIssuersGenerateRootRoute extends Route {} +export default class PkiIssuersGenerateRootRoute extends Route { + @service secretMountPath; + @service store; + + model() { + return this.store.createRecord('pki/action'); + } + + 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: 'generate root' }, + ]; + } +} diff --git a/ui/lib/pki/addon/templates/issuers/generate-root.hbs b/ui/lib/pki/addon/templates/issuers/generate-root.hbs index a26a27ca6..069d422eb 100644 --- a/ui/lib/pki/addon/templates/issuers/generate-root.hbs +++ b/ui/lib/pki/addon/templates/issuers/generate-root.hbs @@ -1 +1,17 @@ -route: issuers.generate-root \ No newline at end of file + + + + + +

+ Generate root +

+
+
+ + \ No newline at end of file diff --git a/ui/tests/integration/components/pki-generate-root-test.js b/ui/tests/integration/components/pki-generate-root-test.js new file mode 100644 index 000000000..6cfccfaa8 --- /dev/null +++ b/ui/tests/integration/components/pki-generate-root-test.js @@ -0,0 +1,191 @@ +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'; +import Sinon from 'sinon'; +import { allowAllCapabilitiesStub } from 'vault/tests/helpers/stubs'; + +const SELECTORS = { + mainSectionTitle: '[data-test-generate-root-title="Root parameters"]', + urlSectionTitle: '[data-test-generate-root-title="Issuer URLs"]', + keyParamsGroupToggle: '[data-test-toggle-group="Key parameters"]', + sanGroupToggle: '[data-test-toggle-group="Subject Alternative Name (SAN) Options"]', + additionalGroupToggle: '[data-test-toggle-group="Additional subject fields"]', + toggleGroupDescription: '[data-test-toggle-group-description]', + formField: '[data-test-field]', + typeField: '[data-test-input="type"]', + fieldByName: (name) => `[data-test-field="${name}"]`, + saveButton: '[data-test-pki-generate-root-save]', + cancelButton: '[data-test-pki-generate-root-cancel]', + formInvalidError: '[data-test-pki-generate-root-validation-error]', +}; + +module('Integration | Component | pki-generate-root', function (hooks) { + setupRenderingTest(hooks); + setupMirage(hooks); + setupEngine(hooks, 'pki'); + + hooks.beforeEach(async function () { + this.server.post('/sys/capabilities-self', allowAllCapabilitiesStub()); + this.store = this.owner.lookup('service:store'); + this.secretMountPath = this.owner.lookup('service:secret-mount-path'); + this.secretMountPath.currentPath = 'pki-test'; + this.model = this.store.createRecord('pki/action', { + role: 'my-role', + }); + this.onSave = Sinon.spy(); + this.onCancel = Sinon.spy(); + }); + + test('it renders with correct sections', async function (assert) { + await render( + hbs``, + { + owner: this.engine, + } + ); + + // Titles + assert.dom(SELECTORS.mainSectionTitle).hasText('Root parameters'); + // TODO: Add this back once URLs section is added + // assert.dom('h2').exists({ count: 2 }, 'two H2 titles are visible on page load'); + // assert.dom(SELECTORS.urlSectionTitle).hasText('Issuer URLs'); + + assert.dom('[data-test-toggle-group]').exists({ count: 3 }, '3 toggle groups shown'); + }); + + test('it shows the appropriate fields under the toggles', async function (assert) { + await render( + hbs``, + { + owner: this.engine, + } + ); + + await click(SELECTORS.additionalGroupToggle); + assert + .dom(SELECTORS.toggleGroupDescription) + .hasText('These fields provide more information about the client to which the certificate belongs.'); + assert + .dom(`[data-test-group="Additional subject fields"] ${SELECTORS.formField}`) + .exists({ count: 7 }, '7 form fields under Additional Fields toggle'); + + await click(SELECTORS.sanGroupToggle); + assert + .dom(SELECTORS.toggleGroupDescription) + .hasText( + '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.' + ); + assert + .dom(`[data-test-group="Subject Alternative Name (SAN) Options"] ${SELECTORS.formField}`) + .exists({ count: 6 }, '7 form fields under SANs toggle'); + + await click(SELECTORS.keyParamsGroupToggle); + assert + .dom(SELECTORS.toggleGroupDescription) + .hasText( + 'Please choose a type to see key parameter options.', + 'Shows empty state description before type is selected' + ); + assert + .dom(`[data-test-group="Key parameters"] ${SELECTORS.formField}`) + .exists({ count: 0 }, '0 form fields under keyParams toggle'); + }); + + test('it renders the correct form fields in key params', async function (assert) { + this.set('type', ''); + await render( + hbs``, + { + owner: this.engine, + } + ); + await click(SELECTORS.keyParamsGroupToggle); + assert + .dom(`[data-test-group="Key parameters"] ${SELECTORS.formField}`) + .exists({ count: 0 }, '0 form fields under keyParams toggle'); + + this.set('type', 'exported'); + await fillIn(SELECTORS.typeField, this.type); + assert + .dom(SELECTORS.toggleGroupDescription) + .hasText( + '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.', + `has correct description for type=${this.type}` + ); + assert.strictEqual(this.model.type, this.type); + assert + .dom(`[data-test-group="Key parameters"] ${SELECTORS.formField}`) + .exists({ count: 3 }, '3 form fields under keyParams toggle'); + assert.dom(SELECTORS.fieldByName('keyName')).exists(`Key name field shown when type=${this.type}`); + assert.dom(SELECTORS.fieldByName('keyType')).exists(`Key type field shown when type=${this.type}`); + assert.dom(SELECTORS.fieldByName('keyBits')).exists(`Key bits field shown when type=${this.type}`); + + this.set('type', 'internal'); + await fillIn(SELECTORS.typeField, this.type); + assert + .dom(SELECTORS.toggleGroupDescription) + .hasText( + '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.', + `has correct description for type=${this.type}` + ); + assert.strictEqual(this.model.type, this.type); + assert + .dom(`[data-test-group="Key parameters"] ${SELECTORS.formField}`) + .exists({ count: 3 }, '3 form fields under keyParams toggle'); + assert.dom(SELECTORS.fieldByName('keyName')).exists(`Key name field shown when type=${this.type}`); + assert.dom(SELECTORS.fieldByName('keyType')).exists(`Key type field shown when type=${this.type}`); + assert.dom(SELECTORS.fieldByName('keyBits')).exists(`Key bits field shown when type=${this.type}`); + + this.set('type', 'existing'); + await fillIn(SELECTORS.typeField, this.type); + assert + .dom(SELECTORS.toggleGroupDescription) + .hasText( + '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.', + `has correct description for type=${this.type}` + ); + assert.strictEqual(this.model.type, this.type); + assert + .dom(`[data-test-group="Key parameters"] ${SELECTORS.formField}`) + .exists({ count: 1 }, '1 form field under keyParams toggle'); + assert.dom(SELECTORS.fieldByName('keyRef')).exists(`Key reference field shown when type=${this.type}`); + + this.set('type', 'kms'); + await fillIn(SELECTORS.typeField, this.type); + assert + .dom(SELECTORS.toggleGroupDescription) + .hasText( + '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. Learn more about managed keys.', + `has correct description for type=${this.type}` + ); + assert.strictEqual(this.model.type, this.type); + assert + .dom(`[data-test-group="Key parameters"] ${SELECTORS.formField}`) + .exists({ count: 3 }, '3 form fields under keyParams toggle'); + assert.dom(SELECTORS.fieldByName('keyName')).exists(`Key name field shown when type=${this.type}`); + assert + .dom(SELECTORS.fieldByName('managedKeyName')) + .exists(`Managed key name field shown when type=${this.type}`); + assert + .dom(SELECTORS.fieldByName('managedKeyId')) + .exists(`Managed key id field shown when type=${this.type}`); + }); + + test('it shows errors before submit if form is invalid', async function (assert) { + const saveSpy = Sinon.spy(); + this.set('onSave', saveSpy); + await render( + hbs``, + { + owner: this.engine, + } + ); + + await click(SELECTORS.saveButton); + assert.dom(SELECTORS.formInvalidError).exists('Shows overall error form'); + assert.ok(saveSpy.notCalled); + }); +}); diff --git a/ui/tests/unit/adapters/pki/config-test.js b/ui/tests/unit/adapters/pki/action-test.js similarity index 82% rename from ui/tests/unit/adapters/pki/config-test.js rename to ui/tests/unit/adapters/pki/action-test.js index 15093e593..479fe0e82 100644 --- a/ui/tests/unit/adapters/pki/config-test.js +++ b/ui/tests/unit/adapters/pki/action-test.js @@ -3,7 +3,7 @@ import { setupTest } from 'vault/tests/helpers'; import { setupMirage } from 'ember-cli-mirage/test-support'; import { allowAllCapabilitiesStub } from 'vault/tests/helpers/stubs'; -module('Unit | Adapter | pki/config', function (hooks) { +module('Unit | Adapter | pki/action', function (hooks) { setupTest(hooks); setupMirage(hooks); @@ -16,11 +16,11 @@ module('Unit | Adapter | pki/config', function (hooks) { }); test('it exists', function (assert) { - const adapter = this.owner.lookup('adapter:pki/config'); + const adapter = this.owner.lookup('adapter:pki/action'); assert.ok(adapter); }); - module('formType import', function (hooks) { + module('actionType import', function (hooks) { hooks.beforeEach(function () { this.payload = { pem_bundle: `-----BEGIN CERTIFICATE REQUEST----- @@ -51,8 +51,8 @@ module('Unit | Adapter | pki/config', function (hooks) { }); await this.store - .createRecord('pki/config', this.payload) - .save({ adapterOptions: { formType: 'import', useIssuer: false } }); + .createRecord('pki/action', this.payload) + .save({ adapterOptions: { actionType: 'import', useIssuer: false } }); }); test('it calls the correct endpoint when useIssuer = true', async function (assert) { @@ -63,15 +63,15 @@ module('Unit | Adapter | pki/config', function (hooks) { }); await this.store - .createRecord('pki/config', this.payload) - .save({ adapterOptions: { formType: 'import', useIssuer: true } }); + .createRecord('pki/action', this.payload) + .save({ adapterOptions: { actionType: 'import', useIssuer: true } }); }); }); - module('formType generate-root', function () { + module('actionType generate-root', function () { test('it calls the correct endpoint when useIssuer = false', async function (assert) { assert.expect(4); - const adapterOptions = { adapterOptions: { formType: 'generate-root', useIssuer: false } }; + const adapterOptions = { adapterOptions: { actionType: 'generate-root', useIssuer: false } }; this.server.post(`${this.backend}/root/generate/internal`, () => { assert.ok(true, 'request made correctly when type = internal'); return {}; @@ -90,22 +90,22 @@ module('Unit | Adapter | pki/config', function (hooks) { }); await this.store - .createRecord('pki/config', { + .createRecord('pki/action', { type: 'internal', }) .save(adapterOptions); await this.store - .createRecord('pki/config', { + .createRecord('pki/action', { type: 'exported', }) .save(adapterOptions); await this.store - .createRecord('pki/config', { + .createRecord('pki/action', { type: 'existing', }) .save(adapterOptions); await this.store - .createRecord('pki/config', { + .createRecord('pki/action', { type: 'kms', }) .save(adapterOptions); @@ -113,7 +113,7 @@ module('Unit | Adapter | pki/config', function (hooks) { test('it calls the correct endpoint when useIssuer = true', async function (assert) { assert.expect(4); - const adapterOptions = { adapterOptions: { formType: 'generate-root', useIssuer: true } }; + const adapterOptions = { adapterOptions: { actionType: 'generate-root', useIssuer: true } }; this.server.post(`${this.backend}/issuers/generate/root/internal`, () => { assert.ok(true, 'request made correctly when type = internal'); return {}; @@ -132,32 +132,32 @@ module('Unit | Adapter | pki/config', function (hooks) { }); await this.store - .createRecord('pki/config', { + .createRecord('pki/action', { type: 'internal', }) .save(adapterOptions); await this.store - .createRecord('pki/config', { + .createRecord('pki/action', { type: 'exported', }) .save(adapterOptions); await this.store - .createRecord('pki/config', { + .createRecord('pki/action', { type: 'existing', }) .save(adapterOptions); await this.store - .createRecord('pki/config', { + .createRecord('pki/action', { type: 'kms', }) .save(adapterOptions); }); }); - module('formType generate-csr', function () { + module('actionType generate-csr', function () { test('it calls the correct endpoint when useIssuer = false', async function (assert) { assert.expect(4); - const adapterOptions = { adapterOptions: { formType: 'generate-csr', useIssuer: false } }; + const adapterOptions = { adapterOptions: { actionType: 'generate-csr', useIssuer: false } }; this.server.post(`${this.backend}/intermediate/generate/internal`, () => { assert.ok(true, 'request made correctly when type = internal'); return {}; @@ -176,22 +176,22 @@ module('Unit | Adapter | pki/config', function (hooks) { }); await this.store - .createRecord('pki/config', { + .createRecord('pki/action', { type: 'internal', }) .save(adapterOptions); await this.store - .createRecord('pki/config', { + .createRecord('pki/action', { type: 'exported', }) .save(adapterOptions); await this.store - .createRecord('pki/config', { + .createRecord('pki/action', { type: 'existing', }) .save(adapterOptions); await this.store - .createRecord('pki/config', { + .createRecord('pki/action', { type: 'kms', }) .save(adapterOptions); @@ -199,7 +199,7 @@ module('Unit | Adapter | pki/config', function (hooks) { test('it calls the correct endpoint when useIssuer = true', async function (assert) { assert.expect(4); - const adapterOptions = { adapterOptions: { formType: 'generate-csr', useIssuer: true } }; + const adapterOptions = { adapterOptions: { actionType: 'generate-csr', useIssuer: true } }; this.server.post(`${this.backend}/issuers/generate/intermediate/internal`, () => { assert.ok(true, 'request made correctly when type = internal'); return {}; @@ -218,22 +218,22 @@ module('Unit | Adapter | pki/config', function (hooks) { }); await this.store - .createRecord('pki/config', { + .createRecord('pki/action', { type: 'internal', }) .save(adapterOptions); await this.store - .createRecord('pki/config', { + .createRecord('pki/action', { type: 'exported', }) .save(adapterOptions); await this.store - .createRecord('pki/config', { + .createRecord('pki/action', { type: 'existing', }) .save(adapterOptions); await this.store - .createRecord('pki/config', { + .createRecord('pki/action', { type: 'kms', }) .save(adapterOptions); diff --git a/ui/tests/unit/decorators/model-form-fields-test.js b/ui/tests/unit/decorators/model-form-fields-test.js index 69b122929..6ac1859df 100644 --- a/ui/tests/unit/decorators/model-form-fields-test.js +++ b/ui/tests/unit/decorators/model-form-fields-test.js @@ -18,6 +18,11 @@ const createClass = (propertyNames, groups) => { subText: 'Maybe a checkbox', }) bar; + @attr('number', { + label: 'Baz', + subText: 'A number field', + }) + baz; } return new Foo(); }; @@ -37,6 +42,11 @@ module('Unit | Decorators | ModelFormFields', function (hooks) { options: { label: 'Bar', subText: 'Maybe a checkbox' }, type: 'boolean', }; + this.bazField = { + name: 'baz', + options: { label: 'Baz', subText: 'A number field' }, + type: 'number', + }; }); hooks.afterEach(function () { this.spy.restore(); @@ -50,15 +60,14 @@ module('Unit | Decorators | ModelFormFields', function (hooks) { assert.ok(this.spy.calledWith(message), 'Error is printed to console'); }); - test('it should throw error when arguments are not provided', function (assert) { + test('it return allFields when arguments not provided', function (assert) { assert.expect(1); - try { - createClass(); - } catch (error) { - const message = - 'Array of property names and/or array of field groups are required when using withFormFields model decorator'; - assert.strictEqual(error.message, message); - } + const model = createClass(); + assert.deepEqual( + [this.fooField, this.barField, this.bazField], + model.allFields, + 'allFields set on Model class' + ); }); test('it should set formFields prop on Model class', function (assert) { diff --git a/ui/tests/unit/serializers/pki/config-test.js b/ui/tests/unit/serializers/pki/action-test.js similarity index 71% rename from ui/tests/unit/serializers/pki/config-test.js rename to ui/tests/unit/serializers/pki/action-test.js index 6bb72fffc..fe37bd4bd 100644 --- a/ui/tests/unit/serializers/pki/config-test.js +++ b/ui/tests/unit/serializers/pki/action-test.js @@ -1,19 +1,19 @@ import { module, test } from 'qunit'; import { setupTest } from 'vault/tests/helpers'; -module('Unit | Serializer | pki/config', function (hooks) { +module('Unit | Serializer | pki/action', function (hooks) { setupTest(hooks); test('it exists', function (assert) { const store = this.owner.lookup('service:store'); - const serializer = store.serializerFor('pki/config'); + const serializer = store.serializerFor('pki/action'); assert.ok(serializer); }); test('it serializes records', function (assert) { const store = this.owner.lookup('service:store'); - const record = store.createRecord('pki/config', {}); + const record = store.createRecord('pki/action', {}); const serializedRecord = record.serialize(); diff --git a/ui/types/ember-data/types/registries/model.d.ts b/ui/types/ember-data/types/registries/model.d.ts index cf3025cee..65ec937d9 100644 --- a/ui/types/ember-data/types/registries/model.d.ts +++ b/ui/types/ember-data/types/registries/model.d.ts @@ -1,10 +1,10 @@ import Model from '@ember-data/model'; +import PkiActionModel from 'vault/models/pki/action'; import PkiCertificateGenerateModel from 'vault/models/pki/certificate/generate'; -import PkiConfigImportModel from 'vault/models/pki/config/import'; declare module 'ember-data/types/registries/model' { export default interface ModelRegistry { - 'pki/config/import': PkiConfigImportModel; + 'pki/action': PkiActionModel; 'pki/certificate/generate': PkiCertificateGenerateModel; // Catchall for any other models [key: string]: any; diff --git a/ui/types/vault/models/pki/config.d.ts b/ui/types/vault/models/pki/action.d.ts similarity index 89% rename from ui/types/vault/models/pki/config.d.ts rename to ui/types/vault/models/pki/action.d.ts index 991d2abe7..a732518bf 100644 --- a/ui/types/vault/models/pki/config.d.ts +++ b/ui/types/vault/models/pki/action.d.ts @@ -1,6 +1,6 @@ import Model from '@ember-data/model'; -export default class PkiConfigModel extends Model { +export default class PkiActionModel extends Model { secretMountPath: unknown; pemBundle: string; type: string;