From 71dee6b2e5ae2d9f0f3e60d93d06cfecb9181ca0 Mon Sep 17 00:00:00 2001 From: Vault Automation Date: Tue, 18 Nov 2025 11:42:22 -0500 Subject: [PATCH] [UI] Ember Data Migration - PKI Configuration (#10328) (#10523) * [UI] Ember Data Migration - PKI Config Setup (#10320) * adds api and capabilities services to pki engine * updates eslintrc to ignore rest siblings for no-unused-vars rule * adds ember-template-lint to pki engine * updates check-issuers decorator to use api service * adds constants for pki capabilities paths * updates pki configuration route to use api service and fetch capabilities * [UI] Ember Data Migration - PKI Config Generate Form (#10322) * updates form class data object to tracked * adds isNot validator * updates tsconfig to resolve json modules * updates open-api form class to use the spec file rather than help response for form field/group generation * adds pki config generate form * [UI] Ember Data Migration - PKI Config Create (#10331) * updates pki configure create route and component * updates pki generate csr component to use api service and form class * updates pki generate root component to use api service and form class * updates pki import bundle component to use api service * [UI] Ember Data Migration - PKI Config Generate Sub Components (#10332) * updates pki generate toggle groups component to support form class * updates pki key parameters component to support form class * updates pki generate immediate component based on csr component changes * updates pki generate root component based on root component changes * more pki config sub component updates * updates pki issuer rotate root component to use api serivce and form * updates pki acceptance tests (#10341) * [UI] Ember Data Migration - PKI Configuration Edit (#10339) * adds forms for pki config acme, cluster, crl and urls * updates pki config edit worflow to use api service and forms * updates pki config details workflow to use api service (#10340) * updates auth configure section route to pass schema key to OpenApiForm constructor Co-authored-by: Jordan Reimer --- ui/.eslintrc.js | 1 + ui/app/app.js | 2 + ui/app/forms/form.ts | 3 +- ui/app/forms/open-api.ts | 103 ++-- ui/app/forms/secrets/pki/config/acme.ts | 53 ++ ui/app/forms/secrets/pki/config/cluster.ts | 24 + ui/app/forms/secrets/pki/config/crl.ts | 88 ++++ ui/app/forms/secrets/pki/config/generate.ts | 77 +++ ui/app/forms/secrets/pki/config/urls.ts | 15 + .../settings/auth/configure/section.ts | 65 ++- ui/app/utils/constants/capabilities.ts | 23 + ui/app/utils/forms/field.ts | 4 + ui/app/utils/forms/validators.js | 4 + .../page/pki-configuration-details.hbs | 186 ++++--- .../page/pki-configuration-details.ts | 13 +- .../page/pki-configuration-edit.hbs | 52 +- .../components/page/pki-configuration-edit.ts | 115 +++-- .../components/page/pki-configure-create.hbs | 46 +- .../components/page/pki-configure-create.ts | 14 +- .../page/pki-issuer-generate-intermediate.hbs | 4 +- .../page/pki-issuer-generate-intermediate.ts | 5 +- .../page/pki-issuer-generate-root.hbs | 17 +- .../page/pki-issuer-generate-root.ts | 5 +- .../components/page/pki-issuer-import.hbs | 3 +- .../page/pki-issuer-rotate-root.hbs | 43 +- .../components/page/pki-issuer-rotate-root.ts | 96 ++-- .../pki/addon/components/pki-generate-csr.hbs | 49 +- .../pki/addon/components/pki-generate-csr.ts | 138 +++-- .../addon/components/pki-generate-root.hbs | 105 ++-- .../pki/addon/components/pki-generate-root.ts | 212 +++++--- .../components/pki-generate-toggle-groups.hbs | 16 +- .../components/pki-generate-toggle-groups.ts | 38 +- .../components/pki-import-pem-bundle.hbs | 6 +- .../addon/components/pki-import-pem-bundle.ts | 98 ++-- .../addon/components/pki-key-parameters.hbs | 31 +- .../addon/components/pki-key-parameters.ts | 32 +- .../components/pki-not-valid-after-form.ts | 26 +- .../components/pki-sign-intermediate-form.hbs | 2 +- ui/lib/pki/addon/decorators/check-issuers.js | 24 +- ui/lib/pki/addon/engine.js | 2 + ui/lib/pki/addon/routes/configuration.js | 40 +- .../pki/addon/routes/configuration/create.js | 11 - ui/lib/pki/addon/routes/configuration/edit.js | 19 +- .../pki/addon/routes/configuration/index.js | 11 +- .../routes/issuers/generate-intermediate.js | 7 - .../pki/addon/routes/issuers/generate-root.js | 7 - .../routes/issuers/issuer/rotate-root.js | 10 +- .../addon/templates/configuration/create.hbs | 3 +- .../addon/templates/configuration/edit.hbs | 11 +- .../addon/templates/configuration/index.hbs | 3 +- .../issuers/generate-intermediate.hbs | 2 +- .../addon/templates/issuers/generate-root.hbs | 2 +- .../templates/issuers/issuer/rotate-root.hbs | 2 +- ui/lib/pki/addon/utils/action-params.js | 8 +- ui/lib/pki/package.json | 1 + .../acceptance/pki/pki-action-forms-test.js | 78 ++- .../acceptance/pki/pki-configuration-test.js | 27 +- .../acceptance/pki/pki-cross-sign-test.js | 2 +- .../pki/pki-engine-route-cleanup-test.js | 127 +---- .../pki/pki-engine-workflow-test.js | 6 +- ui/tests/helpers/pki/pki-helpers.ts | 8 + ui/tests/helpers/pki/pki-selectors.ts | 6 +- .../page/pki-configuration-details-test.js | 145 +++--- .../pki/page/pki-configuration-edit-test.js | 479 +++++++----------- .../pki/page/pki-configure-create-test.js | 16 +- .../pki-issuer-generate-intermediate-test.js | 4 +- .../pki/page/pki-issuer-generate-root-test.js | 33 +- .../pki/page/pki-issuer-rotate-root-test.js | 181 +++---- .../components/pki/pki-generate-csr-test.js | 181 ++++--- .../components/pki/pki-generate-root-test.js | 167 +++--- .../pki/pki-generate-toggle-groups-test.js | 61 ++- .../pki/pki-import-pem-bundle-test.js | 189 +++---- ui/tsconfig.json | 2 + ui/types/vault/app-types.ts | 18 +- 74 files changed, 1972 insertions(+), 1735 deletions(-) create mode 100644 ui/app/forms/secrets/pki/config/acme.ts create mode 100644 ui/app/forms/secrets/pki/config/cluster.ts create mode 100644 ui/app/forms/secrets/pki/config/crl.ts create mode 100644 ui/app/forms/secrets/pki/config/generate.ts create mode 100644 ui/app/forms/secrets/pki/config/urls.ts diff --git a/ui/.eslintrc.js b/ui/.eslintrc.js index 29ad4489cc..af44c06ee8 100644 --- a/ui/.eslintrc.js +++ b/ui/.eslintrc.js @@ -39,6 +39,7 @@ module.exports = { 'ember/no-actions-hash': 'off', 'ember/require-tagless-components': 'off', 'ember/no-component-lifecycle-hooks': 'off', + '@typescript-eslint/no-unused-vars': ['error', { ignoreRestSiblings: true }], }, overrides: [ // node files diff --git a/ui/app/app.js b/ui/app/app.js index 8ad797547c..1637f87aac 100644 --- a/ui/app/app.js +++ b/ui/app/app.js @@ -120,7 +120,9 @@ export default class App extends Application { pki: { dependencies: { services: [ + 'api', 'auth', + 'capabilities', 'download', 'flash-messages', 'namespace', diff --git a/ui/app/forms/form.ts b/ui/app/forms/form.ts index 24931001e1..c34b780a65 100644 --- a/ui/app/forms/form.ts +++ b/ui/app/forms/form.ts @@ -4,6 +4,7 @@ */ import { validate } from 'vault/utils/forms/validate'; import { set } from '@ember/object'; +import { TrackedObject } from 'tracked-built-ins'; import type { Validations } from 'vault/app-types'; import type FormField from 'vault/utils/forms/field'; @@ -24,7 +25,7 @@ export default class Form { fieldGroupProps = ['formFieldGroups']; constructor(data: Partial = {}, options: FormOptions = {}, validations?: Validations) { - this.data = { ...data } as T; + this.data = new TrackedObject(data) as T; this.isNew = options.isNew || false; // typically this would be defined on the subclass // if validations are conditional, it may be preferable to define them during instantiation diff --git a/ui/app/forms/open-api.ts b/ui/app/forms/open-api.ts index cdd6ece5b4..8c24406688 100644 --- a/ui/app/forms/open-api.ts +++ b/ui/app/forms/open-api.ts @@ -6,52 +6,75 @@ import Form from 'vault/forms/form'; import FormField from 'vault/utils/forms/field'; import FormFieldGroup from 'vault/utils/forms/field-group'; -import { propsForSchema } from 'vault/utils/openapi-helpers'; +import { expandOpenApiProps } from 'vault/utils/openapi-helpers'; +import openApiSpec from '@hashicorp/vault-client-typescript/openapi.json'; -import type { OpenApiHelpResponse } from 'vault/utils/openapi-helpers'; +import type { OpenApiProps } from 'vault/utils/openapi-helpers'; export default class OpenApiForm extends Form { - declare formFieldGroups: FormFieldGroup[]; + formFieldGroups: FormFieldGroup[] = []; + formFields: FormField[] = []; - constructor(helpResponse: OpenApiHelpResponse, ...formArgs: ConstructorParameters) { - super(...formArgs); - // create formFieldGroups from the OpenAPI properties - const props = propsForSchema(helpResponse); - const groups: { [groupName: string]: FormField[] } = {}; - // iterate over the properties and organize them into groups - for (const [name, prop] of Object.entries(props)) { - // disabling lint rule since we need to ignore certain options returned from expandOpenApiProps util - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const { fieldGroup, fieldValue, type, defaultValue, ...options } = prop; - // groupName from groupsMap takes precedence over fieldGroup from the property - const group = fieldGroup || 'default'; - // organize the form fields so we can create formFieldGroups later - if (!(group in groups)) { - groups[group] = []; - } - // create a new FormField for the property and associate it with the appropriate fieldGroup - // props marked as `identifier` are primary fields that should be rendered first in the form - const arrMethod = options.identifier ? 'unshift' : 'push'; - groups[group]?.[arrMethod](new FormField(name, type, options)); - // set the default value on the data object - if (defaultValue && this.data[name as keyof typeof this.data] === undefined) { - this.data = { ...this.data, [name]: defaultValue }; + constructor(schemaKey: string, ...formArgs: ConstructorParameters) { + const [data = {}, ...restArgs] = formArgs; + const defaultValues = {} as Partial; + const formFields: FormField[] = []; + let formFieldGroups: FormFieldGroup[] = []; + // find the schema in the OpenAPI spec that contains the properties for dynamic form generation + const schema = openApiSpec.components.schemas[schemaKey as keyof typeof openApiSpec.components.schemas]; + // there could be an instance where the schema isn't found but an inherited class has defined static fields + // in that case we will simply bypass dynamic form generation in favor of throwing an error + if (schema) { + // create formFieldGroups from the OpenAPI properties + const props = expandOpenApiProps(schema.properties as OpenApiProps, 'form'); + const groups: { [groupName: string]: FormField[] } = {}; + // iterate over the properties and organize them into groups + for (const [name, prop] of Object.entries(props)) { + // disabling lint rule since we need to ignore certain options returned from expandOpenApiProps util + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { fieldGroup, fieldValue, type, defaultValue, ...options } = prop; + // groupName from groupsMap takes precedence over fieldGroup from the property + const group = fieldGroup || 'default'; + // organize the form fields so we can create formFieldGroups later + if (!(group in groups)) { + groups[group] = []; + } + // create a new FormField for the property and associate it with the appropriate fieldGroup + // props marked as `identifier` are primary fields that should be rendered first in the form + const arrMethod = options.identifier ? 'unshift' : 'push'; + const field = new FormField(name, type, options); + groups[group]?.[arrMethod](field); + // push all fields to formFields array for convenience access and flexibility + formFields.push(field); + // set the default value from the schema if it is not already set in the data + if (defaultValue !== undefined && data[name as keyof typeof data] === undefined) { + defaultValues[name as keyof T] = defaultValue as T[keyof T]; + } } + + // create formFieldGroups from the expanded groups + formFieldGroups = Object.entries(groups).reduce( + (formFieldGroups, [groupName, fields]) => { + const group = new FormFieldGroup(groupName, fields); + // ensure the default group is the first group to render + if (groupName === 'default') { + return [group, ...formFieldGroups]; + } + return [...formFieldGroups, group]; + }, + [] + ); + } else { + // to aide in development log out an error to the console if the schema is not found + // eslint-disable-next-line no-console + console.error(`OpenApiForm: Schema '${schemaKey}' not found in OpenAPI spec.`); } - // ensure default group is the first item in the formFieldGroups - - // create formFieldGroups from the expanded groups - this.formFieldGroups = Object.entries(groups).reduce( - (formFieldGroups, [groupName, fields]) => { - const group = new FormFieldGroup(groupName, fields); - // ensure the default group is the first group to render - if (groupName === 'default') { - return [group, ...formFieldGroups]; - } - return [...formFieldGroups, group]; - }, - [] - ); + // call the super constructor with the merged default values and data + super({ ...defaultValues, ...data }, ...restArgs); + // add the generated form fields and groups to the instance + // this allows for a base class to define static form fields/groups and have them merged with the dynamic ones if necessary + this.formFields.push(...formFields); + this.formFieldGroups.push(...formFieldGroups); } } diff --git a/ui/app/forms/secrets/pki/config/acme.ts b/ui/app/forms/secrets/pki/config/acme.ts new file mode 100644 index 0000000000..81b01049a9 --- /dev/null +++ b/ui/app/forms/secrets/pki/config/acme.ts @@ -0,0 +1,53 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: BUSL-1.1 + */ + +import Form from 'vault/forms/form'; +import FormField from 'vault/utils/forms/field'; + +import type { PkiConfigureAcmeRequest } from '@hashicorp/vault-client-typescript'; + +export default class PkiConfigAcmeForm extends Form { + formFields = [ + new FormField('enabled', 'boolean', { + label: 'ACME enabled', + subText: 'When ACME is disabled, all requests to ACME directory URLs will return 404.', + }), + new FormField('default_directory_policy', 'string', { + subText: + "Specifies the behavior of the default ACME directory. Can be 'forbid', 'sign-verbatim' or a role given by 'role:'. If a role is used, it must be present in 'allowed_roles'.", + }), + new FormField('allowed_roles', 'string', { + editType: 'stringArray', + subText: + "The default value '*' allows every role within the mount to be used. If the default_directory_policy specifies a role, it must be allowed under this configuration.", + }), + new FormField('allowed_role_ext_key_usage', 'boolean', { + label: 'Allow role ExtKeyUsage', + subText: + "When enabled, respect the role's ExtKeyUsage flags. Otherwise, ACME certificates are forced to ServerAuth.", + }), + new FormField('allowed_issuers', 'string', { + editType: 'stringArray', + subText: + "Specifies a list of issuers allowed to issue certificates via explicit ACME paths. If an allowed role specifies an issuer outside this list, it will be allowed. The default value '*' allows every issuer within the mount.", + }), + new FormField('eab_policy', 'string', { + label: 'EAB policy', + possibleValues: ['not-required', 'new-account-required', 'always-required'], + }), + new FormField('dns_resolver', 'string', { + label: 'DNS resolver', + subText: + 'An optional overriding DNS resolver to use for challenge verification lookups. When not specified, the default system resolver will be used. This allows domains on peered networks with an accessible DNS resolver to be validated.', + }), + new FormField('max_ttl', 'string', { + label: 'Max TTL', + editType: 'ttl', + hideToggle: true, + helperTextEnabled: + 'Specify the maximum TTL for ACME certificates. Role TTL values will be limited to this value.', + }), + ]; +} diff --git a/ui/app/forms/secrets/pki/config/cluster.ts b/ui/app/forms/secrets/pki/config/cluster.ts new file mode 100644 index 0000000000..f87e48d880 --- /dev/null +++ b/ui/app/forms/secrets/pki/config/cluster.ts @@ -0,0 +1,24 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: BUSL-1.1 + */ + +import Form from 'vault/forms/form'; +import FormField from 'vault/utils/forms/field'; + +import type { PkiConfigureClusterRequest } from '@hashicorp/vault-client-typescript'; + +export default class PkiConfigClusterForm extends Form { + formFields = [ + new FormField('path', 'string', { + label: "Mount's API path", + subText: + "Specifies the path to this performance replication cluster's API mount path, including any namespaces as path components. This address is used for the ACME directories, which must be served over a TLS-enabled listener.", + }), + new FormField('aia_path', 'string', { + label: 'AIA path', + subText: + "Specifies the path to this performance replication cluster's AIA distribution point; may refer to an external, non-Vault responder.", + }), + ]; +} diff --git a/ui/app/forms/secrets/pki/config/crl.ts b/ui/app/forms/secrets/pki/config/crl.ts new file mode 100644 index 0000000000..73915d187c --- /dev/null +++ b/ui/app/forms/secrets/pki/config/crl.ts @@ -0,0 +1,88 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: BUSL-1.1 + */ + +import Form from 'vault/forms/form'; +import FormField from 'vault/utils/forms/field'; +import FormFieldGroup from 'vault/utils/forms/field-group'; + +import type { PkiConfigureCrlRequest } from '@hashicorp/vault-client-typescript'; + +export default class PkiConfigCrlForm extends Form { + crlFields = [ + new FormField('expiry', 'string', { + label: 'Expiry', + labelDisabled: 'No expiry', + mapToBoolean: 'disable', + isOppositeValue: true, + editType: 'ttl', + helperTextDisabled: 'The CRL will not be built.', + helperTextEnabled: 'The CRL will expire after:', + }), + new FormField('auto_rebuild_grace_period', 'string', { + label: 'Auto-rebuild on', + labelDisabled: 'Auto-rebuild off', + mapToBoolean: 'auto_rebuild', + isOppositeValue: false, + editType: 'ttl', + helperTextEnabled: 'Vault will rebuild the CRL in the below grace period before expiration', + helperTextDisabled: 'Vault will not automatically rebuild the CRL', + }), + new FormField('delta_rebuild_interval', 'string', { + label: 'Delta CRL building on', + labelDisabled: 'Delta CRL building off', + mapToBoolean: 'enable_delta', + isOppositeValue: false, + editType: 'ttl', + helperTextEnabled: 'Vault will rebuild the delta CRL at the interval below:', + helperTextDisabled: 'Vault will not rebuild the delta CRL at an interval', + }), + ]; + + ocspFields = [ + new FormField('ocsp_expiry', 'string', { + label: 'OCSP responder APIs enabled', + labelDisabled: 'OCSP responder APIs disabled', + mapToBoolean: 'ocsp_disable', + isOppositeValue: true, + editType: 'ttl', + helperTextEnabled: "Requests about a certificate's status will be valid for:", + helperTextDisabled: 'Requests cannot be made to check if an individual certificate is valid.', + }), + ]; + + revocationFields = [ + new FormField('cross_cluster_revocation', 'boolean', { + label: 'Cross-cluster revocation', + helpText: + 'Enables cross-cluster revocation request queues. When a serial not issued on this local cluster is passed to the /revoke endpoint, it is replicated across clusters and revoked by the issuing cluster if it is online.', + }), + new FormField('unified_crl', 'boolean', { + label: 'Unified CRL', + helpText: + 'Enables unified CRL and OCSP building. This synchronizes all revocations between clusters; a single, unified CRL will be built on the active node of the primary performance replication (PR) cluster.', + }), + new FormField('unified_crl_on_existing_paths', 'boolean', { + label: 'Unified CRL on existing paths', + helpText: + 'If enabled, existing CRL and OCSP paths will return the unified CRL instead of a response based on cluster-local data.', + }), + ]; + + formFields = [ + new FormField('auto_rebuild', 'boolean'), + new FormField('enable_delta', 'boolean'), + new FormField('disable', 'boolean'), + new FormField('ocsp_disable', 'boolean'), + ...this.crlFields, + ...this.ocspFields, + ...this.revocationFields, + ]; + + formFieldGroups = [ + new FormFieldGroup('Certificate Revocation List (CRL)', this.crlFields), + new FormFieldGroup('Online Certificate Status Protocol (OCSP)', this.ocspFields), + new FormFieldGroup('Unified Revocation', this.revocationFields), + ]; +} diff --git a/ui/app/forms/secrets/pki/config/generate.ts b/ui/app/forms/secrets/pki/config/generate.ts new file mode 100644 index 0000000000..f33a67b014 --- /dev/null +++ b/ui/app/forms/secrets/pki/config/generate.ts @@ -0,0 +1,77 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: BUSL-1.1 + */ + +import OpenApiForm from 'vault/forms/open-api'; +import FormField from 'vault/utils/forms/field'; + +import type { + PkiGenerateRootRequest, + PkiIssuersGenerateRootRequest, + PkiGenerateIntermediateRequest, + PkiIssuersGenerateIntermediateRequest, +} from '@hashicorp/vault-client-typescript'; +import type { Validations } from 'vault/app-types'; + +type PkiGenerateRequest = + | PkiGenerateRootRequest + | PkiIssuersGenerateRootRequest + | PkiGenerateIntermediateRequest + | PkiIssuersGenerateIntermediateRequest; +type PkiConfigGenerateFormData = PkiGenerateRequest & { + type?: string; + customTtl?: string; + subject_serial_number?: string; +}; + +export default class PkiConfigGenerateForm extends OpenApiForm { + constructor(...args: ConstructorParameters) { + super(...args); + // type and customTtl are UI only fields used to determine which fields to show and which validations to apply + // add them manually to the formFields since they are not included in the helpResponse + this.formFields.push( + new FormField('type', 'string', { + possibleValues: ['exported', 'internal', 'existing', 'kms'], + noDefault: true, + }), + new FormField('customTtl', 'string', { + 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.', + editType: 'yield', + }), + // this property is not documented but seems to be supported? + new FormField('subject_serial_number', 'string', { + subText: + "Specifies the requested Subject's named Serial Number value. This has no impact on the Certificate's serial number randomly generated by Vault.", + }) + ); + } + + validations: Validations = { + type: [{ type: 'presence', message: 'Type is required.' }], + common_name: [{ type: 'presence', message: 'Common name is required.' }], + issuer_name: [ + { + type: 'isNot', + options: { value: 'default' }, + message: `Issuer name must be unique across all issuers and not be the reserved value 'default'.`, + }, + ], + key_name: [ + { + type: 'isNot', + options: { value: 'default' }, + message: `Key name cannot be the reserved value 'default'`, + }, + ], + }; + + toJSON() { + const { data, ...rest } = super.toJSON(this.data); + // remove type and customTtl from payload since they are UI only props + const { type, customTtl, ...payload } = data; + return { data: payload, ...rest }; + } +} diff --git a/ui/app/forms/secrets/pki/config/urls.ts b/ui/app/forms/secrets/pki/config/urls.ts new file mode 100644 index 0000000000..a0588e7cd8 --- /dev/null +++ b/ui/app/forms/secrets/pki/config/urls.ts @@ -0,0 +1,15 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: BUSL-1.1 + */ + +import OpenApiForm from 'vault/forms/open-api'; + +import type { PkiConfigureUrlsRequest } from '@hashicorp/vault-client-typescript'; +import type Form from 'vault/forms/form'; + +export default class PkiConfigUrlsForm extends OpenApiForm { + constructor(...args: ConstructorParameters) { + super('PkiConfigureUrlsRequest', ...args); + } +} diff --git a/ui/app/routes/vault/cluster/settings/auth/configure/section.ts b/ui/app/routes/vault/cluster/settings/auth/configure/section.ts index 90e2a5f1ec..59c2192086 100644 --- a/ui/app/routes/vault/cluster/settings/auth/configure/section.ts +++ b/ui/app/routes/vault/cluster/settings/auth/configure/section.ts @@ -13,8 +13,6 @@ import type PathHelpService from 'vault/services/path-help'; import type Store from '@ember-data/store'; import type { ClusterSettingsAuthConfigureRouteModel } from '../configure'; import type { MountConfig } from 'vault/mount'; -import type { HTTPRequestInit, RequestOpts } from '@hashicorp/vault-client-typescript'; -import type { OpenApiHelpResponse } from 'vault/utils/openapi-helpers'; export default class ClusterSettingsAuthConfigureRoute extends Route { @service declare readonly api: ApiService; @@ -56,46 +54,66 @@ export default class ClusterSettingsAuthConfigureRoute extends Route { }[method.methodType]; } - fetchConfig(type: string, section: string, path: string, help = false) { - const initOverride = help - ? (context: { init: HTTPRequestInit; context: RequestOpts }) => - this.api.addQueryParams(context, { help: 1 }) - : undefined; - + fetchConfig(type: string, section: string, path: string) { switch (type) { case 'aws': { switch (section) { case 'client': - return this.api.auth.awsReadClientConfiguration(path, initOverride); + return this.api.auth.awsReadClientConfiguration(path); case 'identity-accesslist': - return this.api.auth.awsReadIdentityAccessListTidySettings(path, initOverride); + return this.api.auth.awsReadIdentityAccessListTidySettings(path); case 'roletag-denylist': - return this.api.auth.awsReadRoleTagDenyListTidySettings(path, initOverride); + return this.api.auth.awsReadRoleTagDenyListTidySettings(path); } break; } case 'azure': - return this.api.auth.azureReadAuthConfiguration(path, initOverride); + return this.api.auth.azureReadAuthConfiguration(path); case 'github': - return this.api.auth.githubReadConfiguration(path, initOverride); + return this.api.auth.githubReadConfiguration(path); case 'gcp': - return this.api.auth.googleCloudReadAuthConfiguration(path, initOverride); + return this.api.auth.googleCloudReadAuthConfiguration(path); case 'jwt': case 'oidc': - return this.api.auth.jwtReadConfiguration(path, initOverride); + return this.api.auth.jwtReadConfiguration(path); case 'kubernetes': - return this.api.auth.kubernetesReadAuthConfiguration(path, initOverride); + return this.api.auth.kubernetesReadAuthConfiguration(path); case 'ldap': - return this.api.auth.ldapReadAuthConfiguration(path, initOverride); + return this.api.auth.ldapReadAuthConfiguration(path); case 'okta': - return this.api.auth.oktaReadConfiguration(path, initOverride); + return this.api.auth.oktaReadConfiguration(path); case 'radius': - return this.api.auth.radiusReadConfiguration(path, initOverride); + return this.api.auth.radiusReadConfiguration(path); } throw { httpStatus: 404 }; } + schemaForType(type: string, section?: string) { + if (type === 'aws' && section) { + return ( + { + client: 'AwsConfigureClientRequest', + 'identity-accesslist': 'AwsConfigureIdentityAccessListTidyOperationRequest', + 'roletag-denylist': 'AwsConfigureRoleTagDenyListTidyOperationRequest', + }[section] || '' + ); + } + return ( + { + azure: 'AzureConfigureAuthRequest', + github: 'GithubConfigureRequest', + gcp: 'GoogleCloudConfigureAuthRequest', + jwt: 'JwtConfigureRequest', + oidc: 'JwtConfigureRequest', + kubernetes: 'KubernetesConfigureRequest', + ldap: 'LdapConfigureAuthRequest', + okta: 'OktaConfigureRequest', + radius: 'RadiusConfigureRequest', + }[type] || '' + ); + } + async modelForConfiguration(section: string) { const { path, methodType } = this.configRouteModel.method; @@ -113,14 +131,7 @@ export default class ClusterSettingsAuthConfigureRoute extends Route { throw { message, httpsStatus: status }; } } - // make request to fetch OpenAPI properties with help query param - const helpResponse = (await this.fetchConfig( - methodType, - section, - path, - true - )) as unknown as OpenApiHelpResponse; - const form = new OpenApiForm(helpResponse, formData, formOptions); + const form = new OpenApiForm(this.schemaForType(methodType, section), formData, formOptions); // for jwt and oidc types, the jwks_pairs field is not deprecated but we do not render it in the UI // remove the field from the group before rendering the form if (['jwt', 'oidc'].includes(methodType)) { diff --git a/ui/app/utils/constants/capabilities.ts b/ui/app/utils/constants/capabilities.ts index ed2a489c81..cdade5f85b 100644 --- a/ui/app/utils/constants/capabilities.ts +++ b/ui/app/utils/constants/capabilities.ts @@ -27,4 +27,27 @@ export const PATH_MAP = { authMethodConfig: apiPath`auth/${'path'}/config`, authMethodConfigAws: apiPath`auth/${'path'}/config/client`, authMethodDelete: apiPath`sys/auth/${'path'}`, + pkiRevoke: apiPath`${'backend'}/revoke`, + pkiConfigAcme: apiPath`${'backend'}/config/acme`, + pkiConfigCluster: apiPath`${'backend'}/config/cluster`, + pkiConfigCrl: apiPath`${'backend'}/config/crl`, + pkiConfigUrls: apiPath`${'backend'}/config/urls`, + pkiIssuersImportBundle: apiPath`${'backend'}/issuers/import/bundle`, + pkiIssuersGenerateRoot: apiPath`${'backend'}/issuers/generate/root/${'type'}`, + pkiIssuersGenerateIntermediate: apiPath`${'backend'}/issuers/generate/intermediate/${'type'}`, + pkiIssuersCrossSign: apiPath`${'backend'}/issuers/cross-sign`, + pkiIssuer: apiPath`${'backend'}/issuer/${'issuerId'}`, + pkiIssuerSignIntermediate: apiPath`${'backend'}/issuer/${'issuerId'}/sign-intermediate`, + pkiRoot: apiPath`${'backend'}/root`, + pkiRootRotateExported: apiPath`${'backend'}/root/rotate/exported`, + pkiRootRotateInternal: apiPath`${'backend'}/root/rotate/internal`, + pkiRootRotateExisting: apiPath`${'backend'}/root/rotate/existing`, + pkiIntermediateCrossSign: apiPath`${'backend'}/intermediate/cross-sign`, + pkiKey: apiPath`${'backend'}/key/${'keyId'}`, + pkiKeysGenerate: apiPath`${'backend'}/keys/generate`, + pkiKeysImport: apiPath`${'backend'}/keys/import`, + pkiRole: apiPath`${'backend'}/roles/${'id'}`, + pkiIssue: apiPath`${'backend'}/issue/${'id'}`, + pkiSign: apiPath`${'backend'}/sign/${'id'}`, + pkiSignVerbatim: apiPath`${'backend'}/sign-verbatim/${'id'}`, }; diff --git a/ui/app/utils/forms/field.ts b/ui/app/utils/forms/field.ts index fe86780daf..10476c8347 100644 --- a/ui/app/utils/forms/field.ts +++ b/ui/app/utils/forms/field.ts @@ -24,6 +24,10 @@ export interface FieldOptions { placeholder?: string; noDefault?: boolean; isSectionHeader?: boolean; + hideToggle?: boolean; + labelDisabled?: string; + mapToBoolean?: string; + isOppositeValue?: boolean; } export default class FormField { diff --git a/ui/app/utils/forms/validators.js b/ui/app/utils/forms/validators.js index a23f515ec0..4c37c51796 100644 --- a/ui/app/utils/forms/validators.js +++ b/ui/app/utils/forms/validators.js @@ -63,6 +63,9 @@ export const isNonString = (value) => { } }; +// returns true if the value is NOT equal to the comparison value +export const isNot = (value, { value: comparisonValue }) => value !== comparisonValue; + export const WHITESPACE_WARNING = (item) => `${capitalize( item @@ -79,6 +82,7 @@ export default { endsInSlash, isNonString, hasWhitespace, + isNot, WHITESPACE_WARNING, NON_STRING_WARNING, }; diff --git a/ui/lib/pki/addon/components/page/pki-configuration-details.hbs b/ui/lib/pki/addon/components/page/pki-configuration-details.hbs index 7ed34fc087..da133b10cd 100644 --- a/ui/lib/pki/addon/components/page/pki-configuration-details.hbs +++ b/ui/lib/pki/addon/components/page/pki-configuration-details.hbs @@ -3,105 +3,97 @@ SPDX-License-Identifier: BUSL-1.1 }} -{{#if @hasConfig}} - - - {{#if @canDeleteAllIssuers}} - -
- {{/if}} - - Edit configuration - -
-
- - {{#if (not-eq @cluster 403)}} -

- Cluster Config -

- {{#each @cluster.allFields as |attr|}} - + + {{#if @canDeleteAllIssuers}} + - {{/each}} - {{/if}} - - {{#if (not-eq @acme 403)}} -

- ACME Config -

- {{#each @acme.allFields as |attr|}} - - {{/each}} - {{/if}} - - {{#if (not-eq @urls 403)}} -

- Global URLs -

- - - {{/if}} - - {{#if (not-eq @crl 403)}} -

- Certificate Revocation List (CRL) -

- - {{#unless @crl.disable}} - - - - {{if @crl.autoRebuild "On" "Off"}} - - {{#if @crl.autoRebuild}} - - {{/if}} - - - {{if @crl.enableDelta "On" "Off"}} - - {{#if @crl.enableDelta}} - - {{/if}} - {{/unless}} -

- Online Certificate Status Protocol (OCSP) -

- - {{#unless @crl.ocspDisable}} - - {{/unless}} - - {{#if this.isEnterprise}} -

- Unified Revocation -

- - - +
{{/if}} + + Edit configuration + +
+ + +{{#if (not-eq @cluster 403)}} +

+ Cluster Config +

+ + +{{/if}} + +{{#if (not-eq @acme 403)}} +

+ ACME Config +

+ + + + + + + + +{{/if}} + +{{#if (not-eq @urls 403)}} +

+ Global URLs +

+ + +{{/if}} + +{{#if (not-eq @crl 403)}} +

+ Certificate Revocation List (CRL) +

+ + {{#unless @crl.disable}} + + + + {{if @crl.auto_rebuild "On" "Off"}} + + {{#if @crl.auto_rebuild}} + + {{/if}} + + + {{if @crl.enable_delta "On" "Off"}} + + {{#if @crl.enable_delta}} + + {{/if}} + {{/unless}} +

+ Online Certificate Status Protocol (OCSP) +

+ + {{#unless @crl.ocsp_disable}} + + {{/unless}} + + {{#if this.isEnterprise}} +

+ Unified Revocation +

+ + + {{/if}} {{/if}} diff --git a/ui/lib/pki/addon/components/page/pki-configuration-details.ts b/ui/lib/pki/addon/components/page/pki-configuration-details.ts index 96a48d36e6..f787be5e95 100644 --- a/ui/lib/pki/addon/components/page/pki-configuration-details.ts +++ b/ui/lib/pki/addon/components/page/pki-configuration-details.ts @@ -7,10 +7,10 @@ import Component from '@glimmer/component'; import { service } from '@ember/service'; import { action } from '@ember/object'; import { tracked } from '@glimmer/tracking'; -import errorMessage from 'vault/utils/error-message'; + import type RouterService from '@ember/routing/router-service'; import type FlashMessageService from 'vault/services/flash-messages'; -import type Store from '@ember-data/store'; +import type ApiService from 'vault/services/api'; import type VersionService from 'vault/services/version'; interface Args { @@ -18,10 +18,11 @@ interface Args { } export default class PkiConfigurationDetails extends Component { - @service declare readonly store: Store; + @service declare readonly api: ApiService; @service('app-router') declare readonly router: RouterService; @service declare readonly flashMessages: FlashMessageService; @service declare readonly version: VersionService; + @tracked showDeleteAllIssuers = false; get isEnterprise() { @@ -31,14 +32,14 @@ export default class PkiConfigurationDetails extends Component { @action async deleteAllIssuers() { try { - const issuerAdapter = this.store.adapterFor('pki/issuer'); - await issuerAdapter.deleteAllIssuers(this.args.backend); + await this.api.secrets.pkiDeleteRoot(this.args.backend); this.flashMessages.success('Successfully deleted all issuers and keys'); this.showDeleteAllIssuers = false; this.router.transitionTo('vault.cluster.secrets.backend.pki.configuration.index'); } catch (error) { this.showDeleteAllIssuers = false; - this.flashMessages.danger(errorMessage(error)); + const { message } = await this.api.parseError(error); + this.flashMessages.danger(message); } } } diff --git a/ui/lib/pki/addon/components/page/pki-configuration-edit.hbs b/ui/lib/pki/addon/components/page/pki-configuration-edit.hbs index 8f78933934..c734a99376 100644 --- a/ui/lib/pki/addon/components/page/pki-configuration-edit.hbs +++ b/ui/lib/pki/addon/components/page/pki-configuration-edit.hbs @@ -11,7 +11,7 @@
    {{#each this.errors as |error|}}
  • - POST config/{{error.modelName}}: + POST config/{{error.type}}: {{error.message}}
  • {{/each}} @@ -24,9 +24,9 @@ - {{#if @cluster.canSet}} - {{#each @cluster.allFields as |attr|}} - + {{#if @capabilities.canSetCluster}} + {{#each @clusterForm.formFields as |field|}} + {{/each}} {{else}} ACME Config - {{#if @acme.canSet}} - {{#each @acme.allFields as |attr|}} - + {{#if @capabilities.canSetAcme}} + {{#each @acmeForm.formFields as |field|}} + {{/each}} {{else}} Global URLs - {{#if @urls.canSet}} - {{#each @urls.allFields as |attr|}} - + {{#if @capabilities.canSetUrls}} + {{#each @urlsForm.formFields as |field|}} + {{/each}} {{else}}
    - {{#if @crl.canSet}} - {{#each @crl.formFieldGroups as |fieldGroup|}} + {{#if @capabilities.canSetCrl}} + {{#each @crlForm.formFieldGroups as |fieldGroup|}} {{#each-in fieldGroup as |group fields|}} {{#if (or (not-eq group "Unified Revocation") this.isEnterprise)}} {{/if}} - {{#each fields as |attr|}} - {{#if (eq attr.options.editType "ttl")}} - {{#if (or (includes attr.name (array "expiry" "ocspExpiry")) (not @crl.disable))}} - {{#let (get @crl attr.options.mapToBoolean) as |enabled|}} + {{#each fields as |field|}} + {{#if (eq field.options.editType "ttl")}} + {{#if (or (includes field.name (array "expiry" "ocsp_expiry")) (not @crlForm.disable))}} + {{#let (get @crlForm field.options.mapToBoolean) as |enabled|}} {{! 'enabled' is the pki/crl model's boolean attr that corresponds to the duration set by the ttl }}
    {{/let}} {{/if}} {{else}} {{#if this.isEnterprise}} - + {{/if}} {{/if}} {{/each}} @@ -125,7 +125,7 @@
    - {{#if (or @urls.canSet @crl.canSet)}} + {{#if (or @capabilities.canSetUrls @capabilities.canSetCrl)}} ; } interface PkiConfigCrlTtls { - autoRebuildGracePeriod: string; + auto_rebuild_grace_period: string; expiry: string; - deltaRebuildInterval: string; - ocspExpiry: string; + delta_rebuild_interval: string; + ocsp_expiry: string; } interface PkiConfigCrlBooleans { - autoRebuild: boolean; - enableDelta: boolean; + auto_rebuild: boolean; + enable_delta: boolean; disable: boolean; - ocspDisable: boolean; + ocsp_disable: boolean; } interface ErrorObject { - modelName: string; + type: string; message: string; } export default class PkiConfigurationEditComponent extends Component { @service('app-router') declare readonly router: RouterService; @service declare readonly flashMessages: FlashMessageService; @service declare readonly version: VersionService; + @service declare readonly api: ApiService; @tracked invalidFormAlert = ''; @tracked errors: Array = []; @@ -55,38 +60,41 @@ export default class PkiConfigurationEditComponent extends Component { return this.version.isEnterprise; } - @task - @waitFor - *save(event: Event) { - event.preventDefault(); - // first clear errors and sticky flash messages - this.errors = []; - this.flashMessages.clearMessages(); + save = task( + waitFor(async (event: Event) => { + event.preventDefault(); + // first clear errors and sticky flash messages + this.errors = []; + this.flashMessages.clearMessages(); - // modelName is also the API endpoint (i.e. pki/config/cluster) - for (const modelName of ['cluster', 'acme', 'urls', 'crl']) { - const model = this.args[modelName as keyof Args]; - // skip saving and continue to next iteration if user does not have permission - if (!model.canSet) continue; - try { - yield model.save(); - this.flashMessages.success(`Successfully updated config/${modelName}`); - } catch (error) { - const errorObject: ErrorObject = { - modelName, - message: errorMessage(error), - }; - this.flashMessages.danger(`Error updating config/${modelName}`, { sticky: true }); - this.errors = addToArray(this.errors, errorObject); + for (const type of ['cluster', 'acme', 'urls', 'crl']) { + // skip saving and continue to next iteration if user does not have permission + if (!this.args.capabilities[`canSet${capitalize(type)}`]) continue; + + try { + const formKey = `${type}Form` as 'clusterForm' | 'acmeForm' | 'urlsForm' | 'crlForm'; + const { data } = this.args[formKey].toJSON(); + const apiKey = `pkiConfigure${capitalize(type)}` as + | 'pkiConfigureAcme' + | 'pkiConfigureCluster' + | 'pkiConfigureUrls' + | 'pkiConfigureCrl'; + await this.api.secrets[apiKey](this.args.backend, data); + this.flashMessages.success(`Successfully updated config/${type}`); + } catch (error) { + const { message } = await this.api.parseError(error); + this.errors = addToArray(this.errors, { type, message }); + this.flashMessages.danger(`Error updating config/${type}`, { sticky: true }); + } } - } - if (this.errors.length) { - this.invalidFormAlert = 'There was an error submitting this form.'; - } else { - this.router.transitionTo('vault.cluster.secrets.backend.pki.configuration.index'); - } - } + if (this.errors.length) { + this.invalidFormAlert = 'There was an error submitting this form.'; + } else { + this.router.transitionTo('vault.cluster.secrets.backend.pki.configuration.index'); + } + }) + ); @action cancel() { @@ -94,14 +102,17 @@ export default class PkiConfigurationEditComponent extends Component { } @action - handleTtl(attr: FormField, e: TtlEvent) { + handleTtl(field: FormField, e: TtlEvent) { const { enabled, goSafeTimeString } = e; - const ttlAttr = attr.name; - this.args.crl[ttlAttr as keyof PkiConfigCrlTtls] = goSafeTimeString; + const { + name, + options: { mapToBoolean, isOppositeValue }, + } = field; + const { data } = this.args.crlForm; + + data[name as keyof PkiConfigCrlTtls] = goSafeTimeString; // expiry and ocspExpiry both correspond to 'disable' booleans // so when ttl is enabled, the booleans are set to false - this.args.crl[attr.options.mapToBoolean as keyof PkiConfigCrlBooleans] = attr.options.isOppositeValue - ? !enabled - : enabled; + data[mapToBoolean as keyof PkiConfigCrlBooleans] = isOppositeValue ? !enabled : enabled; } } diff --git a/ui/lib/pki/addon/components/page/pki-configure-create.hbs b/ui/lib/pki/addon/components/page/pki-configure-create.hbs index 4dbdb7c807..43a1f47ac9 100644 --- a/ui/lib/pki/addon/components/page/pki-configure-create.hbs +++ b/ui/lib/pki/addon/components/page/pki-configure-create.hbs @@ -14,14 +14,12 @@ -{{#if @config.id}} - -{{else}} +{{#if this.showActionTypes}}
    {{#each this.configTypes as |option|}}
    -
    +{{else}} + {{/if}} -{{#if (eq @config.actionType "import")}} + +{{#if (eq this.actionType "import")}} -{{else if (eq @config.actionType "generate-root")}} - {{#if @config.privateKey}} -
    - - Next steps - - The - private_key - is only available once. Make sure you copy and save it now. - - -
    - {{/if}} +{{else if (eq this.actionType "generate-root")}} -{{else if (eq @config.actionType "generate-csr")}} +{{else if (eq this.actionType "generate-csr")}} {{else}} diff --git a/ui/lib/pki/addon/components/page/pki-configure-create.ts b/ui/lib/pki/addon/components/page/pki-configure-create.ts index 0799082fce..6faedef258 100644 --- a/ui/lib/pki/addon/components/page/pki-configure-create.ts +++ b/ui/lib/pki/addon/components/page/pki-configure-create.ts @@ -6,15 +6,15 @@ import Component from '@glimmer/component'; import { service } from '@ember/service'; import { tracked } from '@glimmer/tracking'; +import { action } from '@ember/object'; import type Store from '@ember-data/store'; import type RouterService from '@ember/routing/router'; import type FlashMessageService from 'vault/services/flash-messages'; -import type PkiActionModel from 'vault/models/pki/action'; -import type { Breadcrumb } from 'vault/vault/app-types'; +import type { Breadcrumb, CapabilitiesMap } from 'vault/vault/app-types'; interface Args { - config: PkiActionModel; + capabilities: CapabilitiesMap; onCancel: CallableFunction; breadcrumbs: Breadcrumb; } @@ -32,6 +32,8 @@ export default class PkiConfigureCreate extends Component { @service('app-router') declare readonly router: RouterService; @tracked title = 'Configure PKI'; + @tracked showActionTypes = true; + @tracked actionType = ''; get configTypes() { return [ @@ -58,4 +60,10 @@ export default class PkiConfigureCreate extends Component { }, ]; } + + @action + onSave(title: string) { + this.title = title; + this.showActionTypes = false; + } } diff --git a/ui/lib/pki/addon/components/page/pki-issuer-generate-intermediate.hbs b/ui/lib/pki/addon/components/page/pki-issuer-generate-intermediate.hbs index 8e50618245..4d99883674 100644 --- a/ui/lib/pki/addon/components/page/pki-issuer-generate-intermediate.hbs +++ b/ui/lib/pki/addon/components/page/pki-issuer-generate-intermediate.hbs @@ -14,11 +14,11 @@ -{{#if @model.id}} +{{#if (eq this.title "View Generated CSR")}} {{/if}} + { diff --git a/ui/lib/pki/addon/components/page/pki-issuer-generate-root.hbs b/ui/lib/pki/addon/components/page/pki-issuer-generate-root.hbs index c93bc782a7..d961aa43d5 100644 --- a/ui/lib/pki/addon/components/page/pki-issuer-generate-root.hbs +++ b/ui/lib/pki/addon/components/page/pki-issuer-generate-root.hbs @@ -14,25 +14,12 @@ -{{#if @model.id}} +{{#if (eq this.title "View generated root")}} {{/if}} -{{#if @model.privateKey}} -
    - - Next steps - - The - private_key - is only available once. Make sure you copy and save it now. - - -
    -{{/if}} + \ No newline at end of file diff --git a/ui/lib/pki/addon/components/page/pki-issuer-generate-root.ts b/ui/lib/pki/addon/components/page/pki-issuer-generate-root.ts index e85b236fd3..fefd28e076 100644 --- a/ui/lib/pki/addon/components/page/pki-issuer-generate-root.ts +++ b/ui/lib/pki/addon/components/page/pki-issuer-generate-root.ts @@ -5,10 +5,11 @@ import Component from '@glimmer/component'; import { tracked } from '@glimmer/tracking'; -import type PkiActionModel from 'vault/vault/models/pki/action'; + +import type { Breadcrumb } from 'vault/app-types'; interface Args { - model: PkiActionModel; + breadcrumbs: Breadcrumb[]; } export default class PagePkiIssuerGenerateRootComponent extends Component { diff --git a/ui/lib/pki/addon/components/page/pki-issuer-import.hbs b/ui/lib/pki/addon/components/page/pki-issuer-import.hbs index 703fe996bf..f07660ef16 100644 --- a/ui/lib/pki/addon/components/page/pki-issuer-import.hbs +++ b/ui/lib/pki/addon/components/page/pki-issuer-import.hbs @@ -18,9 +18,8 @@ {{/if}} \ No newline at end of file diff --git a/ui/lib/pki/addon/components/page/pki-issuer-rotate-root.hbs b/ui/lib/pki/addon/components/page/pki-issuer-rotate-root.hbs index 3d54340b77..d69a3b57ac 100644 --- a/ui/lib/pki/addon/components/page/pki-issuer-rotate-root.hbs +++ b/ui/lib/pki/addon/components/page/pki-issuer-rotate-root.hbs @@ -9,18 +9,18 @@

    - {{if @newRootModel.id "View Issuer Certificate" "Generate New Root"}} + {{if this.newRoot "View Issuer Certificate" "Generate New Root"}}

    -{{#if @newRootModel.id}} - +{{#if this.newRoot}} + Cross-sign issuers @@ -28,7 +28,7 @@ Sign Intermediate @@ -55,7 +55,7 @@
  • Configure @@ -89,13 +89,13 @@ {{/if}} -{{#if @newRootModel.id}} +{{#if this.newRoot}}
    Next steps Your new root has been generated. - {{#if @newRootModel.privateKey}} + {{#if this.newRoot.private_key}} Make sure to copy and save the private_key as it is only available once. @@ -108,7 +108,7 @@ @iconPosition="trailing" @text="Cross-sign issuers" @route="issuers.issuer.cross-sign" - @models={{array @newRootModel.backend @newRootModel.issuerId}} + @models={{array this.secretMountPath.currentPath this.newRoot.issuer_id}} /> @@ -131,9 +131,9 @@ {{/if}} {{#if (eq this.displayedForm "use-old-settings")}} - {{#if @newRootModel.id}} - - + {{#if this.newRoot}} + +
    @@ -155,12 +155,11 @@ {{this.alertBanner}} {{/if}} - {{#let (find-by "name" "commonName" @newRootModel.allFields) as |attr|}} - - {{/let}} - {{#let (find-by "name" "issuerName" @newRootModel.allFields) as |attr|}} - - {{/let}} + {{#each this.newRootForm.formFields as |field|}} + {{#if (includes field.name (array "common_name" "issuer_name"))}} + + {{/if}} + {{/each}}
    {{/if}} \ No newline at end of file diff --git a/ui/lib/pki/addon/components/page/pki-issuer-rotate-root.ts b/ui/lib/pki/addon/components/page/pki-issuer-rotate-root.ts index 0a4f609052..79e1941312 100644 --- a/ui/lib/pki/addon/components/page/pki-issuer-rotate-root.ts +++ b/ui/lib/pki/addon/components/page/pki-issuer-rotate-root.ts @@ -9,19 +9,26 @@ import { tracked } from '@glimmer/tracking'; import { action } from '@ember/object'; import { waitFor } from '@ember/test-waiters'; import { task } from 'ember-concurrency'; -import errorMessage from 'vault/utils/error-message'; +import { underscore, capitalize } from '@ember/string'; +import { parseCertificate } from 'vault/utils/parse-pki-cert'; +import PkiConfigGenerateForm from 'vault/forms/secrets/pki/config/generate'; -import type Store from '@ember-data/store'; import type RouterService from '@ember/routing/router'; import type FlashMessageService from 'vault/services/flash-messages'; import type SecretMountPath from 'vault/services/secret-mount-path'; import type PkiIssuerModel from 'vault/models/pki/issuer'; -import type PkiActionModel from 'vault/vault/models/pki/action'; import type { Breadcrumb, ValidationMap } from 'vault/vault/app-types'; +import type { + PkiGenerateRootResponse, + PkiRotateRootExportedEnum, + PkiRotateRootRequest, +} from '@hashicorp/vault-client-typescript'; +import type { ParsedCertificateData } from 'vault/vault/utils/parse-pki-cert'; +import type ApiService from 'vault/services/api'; interface Args { oldRoot: PkiIssuerModel; - newRootModel: PkiActionModel; + certData: ParsedCertificateData; breadcrumbs: Breadcrumb; parsingErrors: string; } @@ -34,17 +41,24 @@ const RADIO_BUTTON_KEY = { export default class PagePkiIssuerRotateRootComponent extends Component { @service declare readonly flashMessages: FlashMessageService; @service declare readonly secretMountPath: SecretMountPath; - @service declare readonly store: Store; + @service declare readonly api: ApiService; @service('app-router') declare readonly router: RouterService; @tracked displayedForm = RADIO_BUTTON_KEY.oldSettings; @tracked showOldSettings = false; @tracked modelValidations: ValidationMap | null = null; + @tracked declare newRoot: PkiGenerateRootResponse; // form alerts below are only for "use old settings" option // validations/errors for "customize new root" are handled by component @tracked alertBanner = ''; @tracked invalidFormAlert = ''; + newRootForm = new PkiConfigGenerateForm( + 'PkiGenerateRootRequest', + { type: 'internal', ...this.args.certData }, + { isNew: true } + ); + get generateOptions() { return [ { @@ -76,49 +90,45 @@ export default class PagePkiIssuerRotateRootComponent extends Component { 'keyId', 'serialNumber', ]; - return this.args.newRootModel.id ? [...defaultFields, ...addKeyFields] : defaultFields; + return this.newRoot + ? [...defaultFields, ...addKeyFields].map((field) => underscore(field)) + : defaultFields; } - checkFormValidity() { - if (this.args.newRootModel.validate) { - const { isValid, state, invalidFormMessage } = this.args.newRootModel.validate(); - this.modelValidations = state; - this.invalidFormAlert = invalidFormMessage; - return isValid; - } - return true; + get newParsedCertificate() { + return parseCertificate(this.newRoot?.certificate || ''); } - @task - @waitFor - *save(event: Event) { - event.preventDefault(); - const continueSave = this.checkFormValidity(); - if (!continueSave) return; - try { - yield this.args.newRootModel.save({ adapterOptions: { actionType: 'rotate-root' } }); - this.flashMessages.success('Successfully generated root.'); - } catch (e) { - this.alertBanner = errorMessage(e); - this.invalidFormAlert = 'There was a problem generating root.'; - } - } + save = task( + waitFor(async (event: Event) => { + event.preventDefault(); + const { isValid, state, invalidFormMessage, data } = this.newRootForm.toJSON(); + if (isValid) { + const { type } = this.newRootForm.data; + try { + await this.api.secrets.pkiRotateRoot( + type as PkiRotateRootExportedEnum, + this.secretMountPath.currentPath, + data as PkiRotateRootRequest + ); + this.flashMessages.success('Successfully generated root.'); + } catch (e) { + const { message } = await this.api.parseError(e); + this.alertBanner = message; + this.invalidFormAlert = 'There was a problem generating root.'; + } + } else { + this.modelValidations = state; + this.invalidFormAlert = invalidFormMessage; + } + }) + ); @action - async fetchDataForDownload(format: string) { - const endpoint = `/v1/${this.secretMountPath.currentPath}/issuer/${this.args.newRootModel.issuerId}/${format}`; - const adapter = this.store.adapterFor('application'); - try { - return adapter.rawRequest(endpoint, 'GET', { unauthenticated: true }).then(function ( - response: Response - ) { - if (format === 'der') { - return response.blob(); - } - return response.text(); - }); - } catch (e) { - return null; - } + fetchDataForDownload(format: 'der' | 'pem') { + const apiKey = `pkiReadIssuer${capitalize(format)}` as 'pkiReadIssuerDer' | 'pkiReadIssuerPem'; + return this.api.secrets[apiKey](this.newRoot.issuer_id || '', this.secretMountPath.currentPath).catch( + () => null + ); } } diff --git a/ui/lib/pki/addon/components/pki-generate-csr.hbs b/ui/lib/pki/addon/components/pki-generate-csr.hbs index ffc4566321..cff190433e 100644 --- a/ui/lib/pki/addon/components/pki-generate-csr.hbs +++ b/ui/lib/pki/addon/components/pki-generate-csr.hbs @@ -3,7 +3,7 @@ SPDX-License-Identifier: BUSL-1.1 }} -{{#if @model.id}} +{{#if this.config}} {{! Model only has ID once form has been submitted and saved }}
    @@ -11,35 +11,32 @@ Next steps Copy the CSR below for a parent issuer to sign and then import the signed certificate back into this mount. - {{#if @model.privateKey}} + {{#if this.config.private_key}} The private_key is only available once. Make sure you copy and save it now. {{/if}} - {{#each this.showFields as |fieldName|}} - {{#let (find-by "name" fieldName @model.allFields) as |attr|}} - {{#let (get @model attr.name) as |value|}} - - {{#if (and attr.options.isCertificate value)}} + + {{#each this.returnedFields as |fieldName|}} + {{#let (get this.config fieldName) as |value|}} + + {{#if value}} + {{#if (includes fieldName (array "csr" "private_key"))}} - {{else if (eq attr.name "keyId")}} - - {{@model.keyId}} + {{else if (eq fieldName "key_id")}} + + {{value}} - {{else if value}} - {{value}} - {{! it's unlikely but if a value is returned for privateKey and privateKeyType we want to display it, otherwise we show the "internal" badge below }} {{else}} - + {{value}} + {{! it's unlikely but if a value is returned for private_key and private_key_type we want to display it, otherwise we show the "internal" badge below }} {{/if}} - - {{/let}} + {{else}} + + {{/if}} + {{/let}} {{/each}}
    @@ -56,17 +53,21 @@ CSR parameters - {{#each this.formFields as |field|}} - + {{#each this.defaultFields as |fieldName|}} + {{#let (find-by "name" fieldName this.form.formFields) as |field|}} + + {{/let}} {{/each}} - +
    + - + + {{#if this.alert}}
    diff --git a/ui/lib/pki/addon/components/pki-generate-csr.ts b/ui/lib/pki/addon/components/pki-generate-csr.ts index d2202ab061..c656bd109b 100644 --- a/ui/lib/pki/addon/components/pki-generate-csr.ts +++ b/ui/lib/pki/addon/components/pki-generate-csr.ts @@ -6,19 +6,27 @@ import Component from '@glimmer/component'; import { tracked } from '@glimmer/tracking'; import { 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 errorMessage from 'vault/utils/error-message'; +import { toLabel } from 'core/helpers/to-label'; +import PkiConfigGenerateForm from 'vault/forms/secrets/pki/config/generate'; import type FlashMessageService from 'vault/services/flash-messages'; -import type PkiActionModel from 'vault/models/pki/action'; -import type { Model, ValidationMap } from 'vault/app-types'; +import type CapabilitiesService from 'vault/services/capabilities'; +import type ApiService from 'vault/services/api'; +import type SecretMountPath from 'vault/services/secret-mount-path'; +import type { ValidationMap } from 'vault/app-types'; +import type { + PkiGenerateIntermediateExportedEnum, + PkiIssuersGenerateIntermediateExportedEnum, + PkiGenerateIntermediateRequest, + PkiGenerateIntermediateResponse, + PkiIssuersGenerateIntermediateRequest, + PkiIssuersGenerateIntermediateResponse, +} from '@hashicorp/vault-client-typescript'; interface Args { - model: PkiActionModel; - useIssuer: boolean; + form: PkiConfigGenerateForm; onComplete: CallableFunction; onCancel: CallableFunction; onSave?: CallableFunction; @@ -27,7 +35,7 @@ interface Args { /** * @module PkiGenerateCsrComponent * PkiGenerateCsr shows only the fields valid for the generate CSR endpoint. - * This component renders the form, handles the model save and rollback actions, + * This component renders the form, handles saving the data to the server, * and shows the resulting data on success. onCancel is required for the cancel * transition, and if onSave is provided it will call that after save for any * side effects in the parent. @@ -45,65 +53,87 @@ interface Args { */ export default class PkiGenerateCsrComponent extends Component { @service declare readonly flashMessages: FlashMessageService; + @service declare readonly capabilities: CapabilitiesService; + @service declare readonly api: ApiService; + @service declare readonly secretMountPath: SecretMountPath; @tracked modelValidations: ValidationMap | null = null; @tracked error: string | null = null; @tracked alert: string | null = null; + @tracked declare config: PkiGenerateIntermediateResponse | PkiIssuersGenerateIntermediateResponse; - formFields; + form = new PkiConfigGenerateForm('PkiGenerateIntermediateRequest', {}, { isNew: true }); + + defaultFields = [ + 'type', + 'common_name', + 'exclude_cn_from_sans', + 'format', + 'subject_serial_number', + 'add_basic_constraints', + ]; // fields rendered after CSR generation - showFields = ['csr', 'keyId', 'privateKey', 'privateKeyType']; + returnedFields = ['csr', 'key_id', 'private_key', 'private_key_type']; - constructor(owner: unknown, args: Args) { - super(owner, args); - this.formFields = expandAttributeMeta(this.args.model, [ - 'type', - 'commonName', - 'excludeCnFromSans', - 'format', - 'subjectSerialNumber', - 'addBasicConstraints', - ]); - } + detailLabel = (fieldName: string) => { + return ( + { + csr: 'CSR', + key_id: 'Key ID', + }[fieldName] || toLabel([fieldName]) + ); + }; - @action - cancel() { - this.args.model.unloadRecord(); - this.args.onCancel(); - } - - async getCapability(): Promise { + async fetchIssuerCapabilities() { try { - const issuerCapabilities = await this.args.model.generateIssuerCsrPath; - return issuerCapabilities.get('canCreate') === true; - } catch (error) { + const { canCreate } = await this.capabilities.for('pkiIssuersGenerateIntermediate', { + backend: this.secretMountPath.currentPath, + type: this.form.data.type, + }); + return canCreate; + } catch (e) { + // fallback to pkiGenerateIntermediate if capabilities fetch fails return false; } } - @task - @waitFor - *save(event: Event): Generator> { - 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.'); - // This component shows the results, but call `onSave` for any side effects on parent - if (onSave) { - onSave(); - } - window?.scrollTo(0, 0); - } else { - this.modelValidations = state; - this.alert = invalidFormMessage; - } - } catch (e) { - this.error = errorMessage(e); - this.alert = 'There was a problem generating the CSR.'; + generateCsr(canUseIssuer: boolean, data: PkiConfigGenerateForm['data']) { + if (canUseIssuer) { + return this.api.secrets.pkiIssuersGenerateIntermediate( + this.form.data.type as PkiIssuersGenerateIntermediateExportedEnum, + this.secretMountPath.currentPath, + data as PkiIssuersGenerateIntermediateRequest + ); + } else { + return this.api.secrets.pkiGenerateIntermediate( + this.form.data.type as PkiGenerateIntermediateExportedEnum, + this.secretMountPath.currentPath, + data as PkiGenerateIntermediateRequest + ); } } + + save = task( + waitFor(async (event: Event) => { + event.preventDefault(); + try { + const { isValid, state, invalidFormMessage, data } = this.form.toJSON(); + if (isValid) { + const canUseIssuer = await this.fetchIssuerCapabilities(); + this.config = await this.generateCsr(canUseIssuer, data); + this.flashMessages.success('Successfully generated CSR.'); + // This component shows the results, but call `onSave` for any side effects on parent + this.args.onSave?.(); + window?.scrollTo(0, 0); + } else { + this.modelValidations = state; + this.alert = invalidFormMessage; + } + } catch (e) { + const { message } = await this.api.parseError(e); + this.error = message; + this.alert = 'There was a problem generating the CSR.'; + } + }) + ); } diff --git a/ui/lib/pki/addon/components/pki-generate-root.hbs b/ui/lib/pki/addon/components/pki-generate-root.hbs index 8918d50c79..22c02d344c 100644 --- a/ui/lib/pki/addon/components/pki-generate-root.hbs +++ b/ui/lib/pki/addon/components/pki-generate-root.hbs @@ -3,51 +3,60 @@ SPDX-License-Identifier: BUSL-1.1 }} -{{! Show results if model has an ID, which is only generated after save }} -{{#if @model.id}} +{{! Show results if config is provided, else show form to generate root CA }} +{{#if this.config}} + {{#if (and (not @rotateCertData) this.config.private_key)}} +
    + + Next steps + + The + private_key + is only available once. Make sure you copy and save it now. + + +
    + {{/if}} +
    - {{#each this.returnedFields as |field|}} - {{#let (find-by "name" field @model.allFields) as |attr|}} - {{#if attr.options.detailLinkTo}} - - {{get - @model - attr.name - }} + {{#each this.returnedFields as |fieldName|}} + {{#let (this.valueForField fieldName) as |value|}} + {{#if (this.linkForField fieldName)}} + + + {{value}} + - {{else if attr.options.isCertificate}} - - + {{else if (this.isCertificateField fieldName)}} + + {{else}} - + {{/if}} {{/let}} {{/each}} + - {{#if @model.privateKey}} - + {{#if this.config.private_key}} + {{else}} {{/if}} - - {{#if @model.privateKeyType}} - {{@model.privateKeyType}} + + + {{#if this.config.private_key_type}} + {{this.config.private_key_type}} {{else}} {{/if}} - + +