diff --git a/ui/app/components/auth-config-form/config.hbs b/ui/app/components/auth-config-form/config.hbs index 2472682745..3d2838a8c6 100644 --- a/ui/app/components/auth-config-form/config.hbs +++ b/ui/app/components/auth-config-form/config.hbs @@ -6,15 +6,10 @@
- - {{#if @model.attrs}} - {{#each @model.attrs as |attr|}} - - {{/each}} - {{else if @model.fieldGroups}} - - {{/if}} + +
+
+
- + - {{#each @model.tuneAttrs as |attr|}} - {{#if (not (includes attr.name @model.userLockoutConfig.modelAttrs))}} - - {{#if (and (eq attr.name "config.listingVisibility") @model.directLoginLink)}} -
- UI login link: - -
- {{/if}} + {{#each @form.tuneFields as |field|}} + + {{#if (and (eq field.name "config.listing_visibility") this.directLoginLink)}} +
+ UI login link: + +
{{/if}} {{/each}} - {{#if @model.supportsUserLockoutConfig}} + {{#if this.supportsUserLockoutConfig}}
User lockout configuration Specifies the user lockout settings for this auth mount. - {{#each @model.tuneAttrs as |attr|}} - {{#if (includes attr.name @model.userLockoutConfig.modelAttrs)}} - - {{/if}} + {{#each @form.userLockoutConfigFields as |field|}} + {{/each}} {{/if}}
+
diff --git a/ui/app/components/auth-config-form/options.js b/ui/app/components/auth-config-form/options.js deleted file mode 100644 index 5f345291d0..0000000000 --- a/ui/app/components/auth-config-form/options.js +++ /dev/null @@ -1,68 +0,0 @@ -/** - * Copyright (c) HashiCorp, Inc. - * SPDX-License-Identifier: BUSL-1.1 - */ - -import AdapterError from '@ember-data/adapter/error'; -import AuthConfigComponent from './config'; -import { service } from '@ember/service'; -import { task } from 'ember-concurrency'; -import { waitFor } from '@ember/test-waiters'; -import { tracked } from '@glimmer/tracking'; -import errorMessage from 'vault/utils/error-message'; - -/** - * @module AuthConfigForm/Options - * The `AuthConfigForm/Options` is options portion of the auth config form. - * - * @example - * - * - * @property model=null {DS.Model} - The corresponding auth model that is being configured. - * - */ - -export default class AuthConfigOptions extends AuthConfigComponent { - @service flashMessages; - @service router; - - @tracked errorMessage; - - @task - @waitFor - *saveModel(evt) { - evt.preventDefault(); - this.errorMessage = null; - const data = this.args.model.config.serialize(); - data.description = this.args.model.description; - - if (this.args.model.supportsUserLockoutConfig) { - data.user_lockout_config = {}; - this.args.model.userLockoutConfig.apiParams.forEach((attr) => { - if (Object.keys(data).includes(attr)) { - data.user_lockout_config[attr] = data[attr]; - delete data[attr]; - } - }); - } - - // token_type should not be tuneable for the token auth method. - if (this.args.model.methodType === 'token') { - delete data.token_type; - } - - try { - yield this.args.model.tune(data); - } catch (err) { - if (err instanceof AdapterError) { - // because we're not calling model.save the model never updates with - // the error, so we set it manually in the component instead. - this.errorMessage = errorMessage(err); - return; - } - throw err; - } - this.router.transitionTo('vault.cluster.access.methods').followRedirects(); - this.flashMessages.success('The configuration was saved successfully.'); - } -} diff --git a/ui/app/components/auth-config-form/options.ts b/ui/app/components/auth-config-form/options.ts new file mode 100644 index 0000000000..b6a9fa3b60 --- /dev/null +++ b/ui/app/components/auth-config-form/options.ts @@ -0,0 +1,87 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: BUSL-1.1 + */ + +import Component from '@glimmer/component'; +import { service } from '@ember/service'; +import { task } from 'ember-concurrency'; +import { waitFor } from '@ember/test-waiters'; +import { tracked } from '@glimmer/tracking'; +import { supportedTypes } from 'vault/utils/auth-form-helpers'; + +import type AuthMethodForm from 'vault/forms/auth/method'; +import type ApiService from 'vault/services/api'; +import type FlashMessageService from 'vault/services/flash-messages'; +import type RouterService from '@ember/routing/router-service'; +import type { HTMLElementEvent } from 'vault/forms'; +import type NamespaceService from 'vault/services/namespace'; +import type VersionService from 'vault/services/version'; +import type { MountsAuthTuneConfigurationParametersRequest } from '@hashicorp/vault-client-typescript'; + +/** + * @module AuthConfigForm/Options + * The `AuthConfigForm/Options` is options portion of the auth config form. + * + * @example + * + * + * @property form=null {AuthMethodForm} - The corresponding auth method that is being configured. + * + */ + +type Args = { + form: AuthMethodForm; +}; + +export default class AuthConfigOptions extends Component { + @service declare readonly api: ApiService; + @service declare readonly flashMessages: FlashMessageService; + @service declare readonly router: RouterService; + @service declare readonly namespace: NamespaceService; + @service declare readonly version: VersionService; + + @tracked errorMessage: string | null = null; + + get directLoginLink() { + const ns = this.namespace.path; + const nsQueryParam = ns ? `namespace=${encodeURIComponent(ns)}&` : ''; + const { normalizedType, data } = this.args.form; + const isSupported = supportedTypes(this.version.isEnterprise).includes(normalizedType); + return isSupported + ? `${window.origin}/ui/vault/auth?${nsQueryParam}with=${encodeURIComponent(data.path)}` + : ''; + } + + get supportsUserLockoutConfig() { + return ['approle', 'ldap', 'userpass'].includes(this.args.form.normalizedType); + } + + onSubmit = task( + waitFor(async (evt: HTMLElementEvent) => { + evt.preventDefault(); + this.errorMessage = null; + try { + const { form } = this.args; + const { + data: { description, config, user_lockout_config }, + } = form.toJSON(); + const payload = { + description, + ...config, + } as MountsAuthTuneConfigurationParametersRequest; + + if (Object.keys(user_lockout_config).length) { + payload.user_lockout_config = user_lockout_config; + } + + await this.api.sys.mountsAuthTuneConfigurationParameters(form.data.path, payload); + } catch (err) { + const { message } = await this.api.parseError(err); + this.errorMessage = message; + } + this.router.transitionTo('vault.cluster.access.methods').followRedirects(); + this.flashMessages.success('The configuration was saved successfully.'); + }) + ); +} diff --git a/ui/app/components/recovery/page/snapshots/snapshot-manage.ts b/ui/app/components/recovery/page/snapshots/snapshot-manage.ts index 961e48deb6..548a21f399 100644 --- a/ui/app/components/recovery/page/snapshots/snapshot-manage.ts +++ b/ui/app/components/recovery/page/snapshots/snapshot-manage.ts @@ -261,12 +261,19 @@ export default class SnapshotManage extends Component { const headers = this.api.buildHeaders({ namespace }); switch (mountType) { case SupportedSecretBackendsEnum.KV: { - await this.api.secrets.kvV1Write(this.resourcePath, this.mountPath, {}, snapshot_id, headers); + await this.api.secrets.kvV1Write( + this.resourcePath, + this.mountPath, + {}, + snapshot_id, + undefined, + headers + ); break; } case SupportedSecretBackendsEnum.CUBBYHOLE: { this.api.buildHeaders({ namespace: namespace || this.namespace.path }); - await this.api.secrets.cubbyholeWrite(this.resourcePath, {}, snapshot_id, headers); + await this.api.secrets.cubbyholeWrite(this.resourcePath, {}, snapshot_id, undefined, headers); break; } default: { diff --git a/ui/app/forms/auth/method.ts b/ui/app/forms/auth/method.ts index 9c3a266395..89c26480a2 100644 --- a/ui/app/forms/auth/method.ts +++ b/ui/app/forms/auth/method.ts @@ -10,6 +10,41 @@ import FormFieldGroup from 'vault/utils/forms/field-group'; import type { AuthMethodFormData } from 'vault/auth/methods'; export default class AuthMethodForm extends MountForm { + fieldProps = ['tuneFields', 'userLockoutConfigFields']; + + userLockoutConfigFields = [ + new FormField('user_lockout_config.lockout_threshold', 'string', { + label: 'Lockout threshold', + subText: 'Specifies the number of failed login attempts after which the user is locked out, e.g. 15.', + }), + new FormField('user_lockout_config.lockout_duration', undefined, { + label: 'Lockout duration', + helperTextEnabled: 'The duration for which a user will be locked out, e.g. "5s" or "30m".', + editType: 'ttl', + helperTextDisabled: 'No lockout duration configured.', + }), + + new FormField('user_lockout_config.lockout_counter_reset', undefined, { + label: 'Lockout counter reset', + helperTextEnabled: + 'The duration after which the lockout counter is reset with no failed login attempts, e.g. "5s" or "30m".', + editType: 'ttl', + helperTextDisabled: 'No reset duration configured.', + }), + new FormField('user_lockout_config.lockout_disable', 'boolean', { + label: 'Disable lockout for this mount', + subText: 'If checked, disables the user lockout feature for this mount.', + }), + ]; + + get tuneFields() { + const readOnly = ['local', 'seal_wrap']; + return this.formFieldGroups[1]?.['Method Options']?.filter((field) => { + const isTuneable = !readOnly.includes(field.name); + return isTuneable || (field.name === 'token_type' && this.normalizedType === 'token'); + }); + } + formFieldGroups = [ new FormFieldGroup('default', [this.fields.path]), new FormFieldGroup('Method Options', [ diff --git a/ui/app/forms/form.ts b/ui/app/forms/form.ts index e1114a32db..8992269ffa 100644 --- a/ui/app/forms/form.ts +++ b/ui/app/forms/form.ts @@ -18,6 +18,11 @@ export default class Form { declare validations: Validations; declare isNew: boolean; + // used by proxy to determine if the property being accessed is a form field + // override these in subclasses to define additional/different fields defined on the class + fieldProps = ['formFields']; + fieldGroupProps = ['formFieldGroups']; + constructor(data: Partial = {}, options: FormOptions = {}, validations?: Validations) { this.data = { ...data } as T; this.isNew = options.isNew || false; @@ -31,18 +36,25 @@ export default class Form { const proxyTarget = (target: this, prop: string) => { try { // check if the property that is being accessed is a form field - const { formFields, formFieldGroups } = target as { - formFields?: FormField[]; - formFieldGroups?: FormFieldGroup[]; - }; - const fields = Array.isArray(formFields) ? formFields : []; + const fields = this.fieldProps.reduce((fields: FormField[], prop) => { + const formFields = target[prop as keyof this]; + if (Array.isArray(formFields)) { + fields.push(...formFields); + } + return fields; + }, []); // in the case of formFieldGroups we need extract the fields out into a flat array - const groupFields = Array.isArray(formFieldGroups) - ? formFieldGroups.reduce((arr: FormField[], group) => { + const groupFields = this.fieldGroupProps.reduce((groupFields: FormField[], prop) => { + const formFieldGroups = target[prop as keyof this]; + if (Array.isArray(formFieldGroups)) { + const fields = formFieldGroups.reduce((arr: FormField[], group: FormFieldGroup) => { const values = Object.values(group)[0] || []; return [...arr, ...values]; - }, []) - : []; + }, []); + groupFields.push(...fields); + } + return groupFields; + }, []); // combine the formFields and formGroupFields into a single array const allFields = [...fields, ...groupFields]; const formDataKeys = allFields.map((field) => field.name) || []; diff --git a/ui/app/forms/open-api.ts b/ui/app/forms/open-api.ts new file mode 100644 index 0000000000..de960722c3 --- /dev/null +++ b/ui/app/forms/open-api.ts @@ -0,0 +1,57 @@ +/** + * 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 { propsForSchema } from 'vault/utils/openapi-helpers'; + +import type { OpenApiHelpResponse } from 'vault/utils/openapi-helpers'; + +export default class OpenApiForm extends Form { + declare formFieldGroups: FormFieldGroup[]; + + 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 }; + } + } + + // 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]; + }, + [] + ); + } +} diff --git a/ui/app/routes/vault/cluster/settings/auth/configure.js b/ui/app/routes/vault/cluster/settings/auth/configure.js deleted file mode 100644 index d5083883a0..0000000000 --- a/ui/app/routes/vault/cluster/settings/auth/configure.js +++ /dev/null @@ -1,18 +0,0 @@ -/** - * Copyright (c) HashiCorp, Inc. - * SPDX-License-Identifier: BUSL-1.1 - */ - -import Route from '@ember/routing/route'; -import { service } from '@ember/service'; - -export default Route.extend({ - store: service(), - - model() { - const { method } = this.paramsFor(this.routeName); - return this.store.findAll('auth-method').then(() => { - return this.store.peekRecord('auth-method', method); - }); - }, -}); diff --git a/ui/app/routes/vault/cluster/settings/auth/configure.ts b/ui/app/routes/vault/cluster/settings/auth/configure.ts new file mode 100644 index 0000000000..538465bc3c --- /dev/null +++ b/ui/app/routes/vault/cluster/settings/auth/configure.ts @@ -0,0 +1,27 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: BUSL-1.1 + */ + +import Route from '@ember/routing/route'; +import { service } from '@ember/service'; + +import type ApiService from 'vault/services/api'; +import type { ModelFrom } from 'vault/route'; + +export type ClusterSettingsAuthConfigureRouteModel = ModelFrom; + +export default class ClusterSettingsAuthConfigureRoute extends Route { + @service declare readonly api: ApiService; + + async model(params: { method: string }) { + const path = params.method; + const methodOptions = await this.api.sys.authReadConfiguration(path); + + return { + methodOptions, + type: methodOptions.type as string, + id: path, + }; + } +} diff --git a/ui/app/routes/vault/cluster/settings/auth/configure/section.js b/ui/app/routes/vault/cluster/settings/auth/configure/section.js deleted file mode 100644 index a5977f6dd2..0000000000 --- a/ui/app/routes/vault/cluster/settings/auth/configure/section.js +++ /dev/null @@ -1,110 +0,0 @@ -/** - * Copyright (c) HashiCorp, Inc. - * SPDX-License-Identifier: BUSL-1.1 - */ - -import AdapterError from '@ember-data/adapter/error'; -import { service } from '@ember/service'; -import { set } from '@ember/object'; -import Route from '@ember/routing/route'; -import RSVP from 'rsvp'; -import UnloadModelRoute from 'vault/mixins/unload-model-route'; -import { getHelpUrlForModel } from 'vault/utils/openapi-helpers'; - -export default Route.extend(UnloadModelRoute, { - modelPath: 'model.model', - pathHelp: service('path-help'), - store: service(), - - modelType(backendType, section) { - const MODELS = { - 'aws-client': 'auth-config/aws/client', - 'aws-identity-accesslist': 'auth-config/aws/identity-accesslist', - 'aws-roletag-denylist': 'auth-config/aws/roletag-denylist', - 'azure-configuration': 'auth-config/azure', - 'github-configuration': 'auth-config/github', - 'gcp-configuration': 'auth-config/gcp', - 'jwt-configuration': 'auth-config/jwt', - 'oidc-configuration': 'auth-config/oidc', - 'kubernetes-configuration': 'auth-config/kubernetes', - 'ldap-configuration': 'auth-config/ldap', - 'okta-configuration': 'auth-config/okta', - 'radius-configuration': 'auth-config/radius', - }; - return MODELS[`${backendType}-${section}`]; - }, - - beforeModel() { - const { section_name } = this.paramsFor(this.routeName); - if (section_name === 'options') { - return; - } - const { method } = this.paramsFor('vault.cluster.settings.auth.configure'); - const backend = this.modelFor('vault.cluster.settings.auth.configure'); - const modelType = this.modelType(backend.type, section_name); - // If this method returns a string it means we expect to hydrate it with OpenAPI - if (getHelpUrlForModel(modelType)) { - return this.pathHelp.hydrateModel(modelType, method); - } - // if no helpUrl is defined, this is a fully generated model - return this.pathHelp.getNewModel(modelType, method, backend.apiPath); - }, - - model(params) { - const backend = this.modelFor('vault.cluster.settings.auth.configure'); - const { section_name: section } = params; - if (section === 'options') { - return RSVP.hash({ - model: backend, - section, - }); - } - const modelType = this.modelType(backend.type, section); - if (!modelType) { - const error = new AdapterError(); - set(error, 'httpStatus', 404); - throw error; - } - const model = this.store.peekRecord(modelType, backend.id); - if (model) { - return RSVP.hash({ - model, - section, - }); - } - return this.store - .findRecord(modelType, backend.id) - .then((config) => { - config.set('backend', backend); - return RSVP.hash({ - model: config, - section, - }); - }) - .catch((e) => { - let config; - // if you haven't saved a config, the API 404s, so create one here to edit and return it - if (e.httpStatus === 404) { - config = this.store.createRecord(modelType, { - mutableId: backend.id, - }); - config.set('backend', backend); - - return RSVP.hash({ - model: config, - section, - }); - } - throw e; - }); - }, - - actions: { - willTransition() { - if (this.currentModel.model.constructor.modelName !== 'auth-method') { - this.unloadModel(); - return true; - } - }, - }, -}); diff --git a/ui/app/routes/vault/cluster/settings/auth/configure/section.ts b/ui/app/routes/vault/cluster/settings/auth/configure/section.ts new file mode 100644 index 0000000000..7005f39b3b --- /dev/null +++ b/ui/app/routes/vault/cluster/settings/auth/configure/section.ts @@ -0,0 +1,142 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: BUSL-1.1 + */ + +import { service } from '@ember/service'; +import Route from '@ember/routing/route'; +import AuthMethodForm from 'vault/forms/auth/method'; +import OpenApiForm from 'vault/forms/open-api'; + +import type ApiService from 'vault/services/api'; +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; + @service declare readonly pathHelp: PathHelpService; + @service declare readonly store: Store; + + get configRouteModel() { + return this.modelFor('vault.cluster.settings.auth.configure') as ClusterSettingsAuthConfigureRouteModel; + } + + modelForOptions() { + const { methodOptions, id, type } = this.configRouteModel; + const config = methodOptions.config as MountConfig; + const listing_visibility = config.listing_visibility === 'unauth' ? true : false; + + const form = new AuthMethodForm({ + ...methodOptions, + path: id, + config: { ...methodOptions.config, listing_visibility }, + user_lockout_config: {}, + }); + form.type = type; + + return { + form, + section: 'options', + }; + } + + get configFieldGroupsMap() { + const { type } = this.configRouteModel; + return { + kubernetes: { + default: ['kubernetes_host', 'kubernetes_ca_cert', 'disable_local_ca_jwt'], + 'Kubernetes Options': ['token_reviewer_jwt', 'pem_keys', 'use_annotations_as_alias_metadata'], + }, + }[type]; + } + + fetchConfig(type: string, section: string, path: string, help = false) { + const initOverride = help + ? (context: { init: HTTPRequestInit; context: RequestOpts }) => + this.api.addQueryParams(context, { help: 1 }) + : undefined; + + switch (type) { + case 'aws': { + switch (section) { + case 'client': + return this.api.auth.awsReadClientConfiguration(path, initOverride); + case 'identity-accesslist': + return this.api.auth.awsReadIdentityAccessListTidySettings(path, initOverride); + case 'roletag-denylist': + return this.api.auth.awsReadRoleTagDenyListTidySettings(path, initOverride); + } + break; + } + case 'azure': + return this.api.auth.azureReadAuthConfiguration(path, initOverride); + case 'github': + return this.api.auth.githubReadConfiguration(path, initOverride); + case 'gcp': + return this.api.auth.googleCloudReadAuthConfiguration(path, initOverride); + case 'jwt': + case 'oidc': + return this.api.auth.jwtReadConfiguration(path, initOverride); + case 'kubernetes': + return this.api.auth.kubernetesReadAuthConfiguration(path, initOverride); + case 'ldap': + return this.api.auth.ldapReadAuthConfiguration(path, initOverride); + case 'okta': + return this.api.auth.oktaReadConfiguration(path, initOverride); + case 'radius': + return this.api.auth.radiusReadConfiguration(path, initOverride); + } + + throw { httpStatus: 404 }; + } + + async modelForConfiguration(section: string) { + const { id: path, type } = this.configRouteModel; + + const formOptions = { isNew: false }; + let formData; + // make request to fetch configuration data for method + try { + const { data } = await this.fetchConfig(type, section, path); + formData = data as object; + } catch (e) { + const { message, status } = await this.api.parseError(e); + if (status === 404) { + formOptions.isNew = true; + } else { + throw { message, httpsStatus: status }; + } + } + // make request to fetch OpenAPI properties with help query param + const helpResponse = (await this.fetchConfig( + type, + section, + path, + true + )) as unknown as OpenApiHelpResponse; + const form = new OpenApiForm(helpResponse, 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(type)) { + const defaultGroup = form.formFieldGroups[0]?.['default'] || []; + const index = defaultGroup.findIndex((field) => field.name === 'jwks_pairs'); + if (index !== undefined && index >= 0) { + defaultGroup.splice(index, 1); + } + } + + return { + form, + section: 'configuration', + }; + } + + model(params: { section_name: 'options' | 'configuration' }) { + const { section_name: section } = params; + return section === 'options' ? this.modelForOptions() : this.modelForConfiguration(section); + } +} diff --git a/ui/app/routes/vault/cluster/settings/auth/enable.ts b/ui/app/routes/vault/cluster/settings/auth/enable.ts index d2c8f37d5c..c243bf70e6 100644 --- a/ui/app/routes/vault/cluster/settings/auth/enable.ts +++ b/ui/app/routes/vault/cluster/settings/auth/enable.ts @@ -14,6 +14,7 @@ export default class VaultClusterSettingsAuthEnableRoute extends Route { model() { const defaults = { config: { listing_visibility: false }, + user_lockout_config: {}, }; return new AuthMethodForm(defaults, { isNew: true }); } diff --git a/ui/app/services/api.ts b/ui/app/services/api.ts index 54f15aada4..cf963d622a 100644 --- a/ui/app/services/api.ts +++ b/ui/app/services/api.ts @@ -148,9 +148,13 @@ export default class ApiService extends Service { // convenience method for updating the query params object on the request context // eg. this.api.sys.uiConfigListCustomMessages(true, ({ context: { query } }) => { query.authenticated = true }); // -> this.api.sys.uiConfigListCustomMessages(true, (context) => this.api.addQueryParams(context, { authenticated: true })); - addQueryParams(requestContext: { init: HTTPRequestInit; context: RequestOpts }, params: HTTPQuery = {}) { - const { context } = requestContext; + async addQueryParams( + requestContext: { init: HTTPRequestInit; context: RequestOpts }, + params: HTTPQuery = {} + ) { + const { context, init } = requestContext; context.query = { ...context.query, ...params }; + return init; } // accepts an error response and returns { status, message, response, path } diff --git a/ui/app/templates/vault/cluster/settings/auth/configure/section.hbs b/ui/app/templates/vault/cluster/settings/auth/configure/section.hbs index 410d2f5acc..790cef811f 100644 --- a/ui/app/templates/vault/cluster/settings/auth/configure/section.hbs +++ b/ui/app/templates/vault/cluster/settings/auth/configure/section.hbs @@ -4,7 +4,7 @@ }} {{#if (eq this.model.section "options")}} - + {{else}} - + {{/if}} \ No newline at end of file diff --git a/ui/app/utils/openapi-helpers.ts b/ui/app/utils/openapi-helpers.ts index 91b2883e87..3f98229c50 100644 --- a/ui/app/utils/openapi-helpers.ts +++ b/ui/app/utils/openapi-helpers.ts @@ -42,16 +42,87 @@ interface DisplayAttrs { sensitive?: boolean; } interface OpenApiAction { + operationId: string; + tags?: string[]; + responses: Record; + requestBody?: { + content: Record; + required: boolean; + }; parameters: Array<{ name: string }>; } interface OpenApiPath { description?: string; - parameters: OpenApiParameter[]; + parameters?: OpenApiParameter[]; 'x-vault-displayAttrs': DisplayAttrs; get?: OpenApiAction; post?: OpenApiAction; delete?: OpenApiAction; } +interface Attribute { + name: string; + type: string | undefined; + options: { + editType?: string; + fieldGroup?: string; + fieldValue?: string; + label?: string; + readonly?: boolean; + }; +} +interface OpenApiProp { + description: string; + type: string; + 'x-vault-displayAttrs': { + name?: string; + value?: string | number; + group?: string; + sensitive?: boolean; + editType?: string; + description?: string; + identifier?: boolean; + }; + items?: { type: string }; + format?: string; + isId?: boolean; + deprecated?: boolean; + enum?: string[]; +} +interface MixedAttr { + type?: string; + helpText?: string; + editType?: string; + fieldGroup: string; + fieldValue?: string; + label?: string; + readonly?: boolean; + possibleValues?: string[]; + defaultValue?: string | number | (() => string | number); + sensitive?: boolean; + readOnly?: boolean; + name?: string; + identifier?: boolean; + [key: string]: unknown; +} + +export type OpenApiProps = Record; + +export type OpenApiHelpResponse = { + help: string; + openapi: { + paths: OpenApiPath[]; + components: { + schemas: Record; + }; + info: { + title: string; + version: string; + description: string; + license: { name: string; url: string }; + }; + openapi: string; + }; +}; // Take object entries from the OpenAPI response and consolidate them into an object which includes itemTypes, operations, and paths export function reducePathsByPathName(pathsInfo: PathInfo, currentPath: [string, OpenApiPath]): PathInfo { @@ -160,59 +231,18 @@ const OPENAPI_POWERED_MODELS = { 'role-ssh': (backend: string) => `/v1/${backend}/roles/example?help=1`, }; -export function getHelpUrlForModel(modelType: string, backend: string) { - const urlFn = OPENAPI_POWERED_MODELS[modelType as keyof typeof OPENAPI_POWERED_MODELS] as ( - backend: string - ) => string; - if (!urlFn) return null; - return urlFn(backend); +export function getHelpUrlForModel(modelType: string, backend?: string) { + if (modelType in OPENAPI_POWERED_MODELS && backend) { + const urlFn = OPENAPI_POWERED_MODELS[modelType as keyof typeof OPENAPI_POWERED_MODELS]; + return urlFn(backend); + } + return null; } -interface Attribute { - name: string; - type: string | undefined; - options: { - editType?: string; - fieldGroup?: string; - fieldValue?: string; - label?: string; - readonly?: boolean; - }; -} - -interface OpenApiProp { - description: string; - type: string; - 'x-vault-displayAttrs': { - name: string; - value: string | number; - group: string; - sensitive: boolean; - editType?: string; - description?: string; - }; - items?: { type: string }; - format?: string; - isId?: boolean; - deprecated?: boolean; - enum?: string[]; -} -interface MixedAttr { - type?: string; - helpText?: string; - editType?: string; - fieldGroup: string; - fieldValue?: string; - label?: string; - readonly?: boolean; - possibleValues?: string[]; - defaultValue?: string | number | (() => string | number); - sensitive?: boolean; - readOnly?: boolean; - [key: string]: unknown; -} - -export const expandOpenApiProps = function (props: Record): Record { +export const expandOpenApiProps = function ( + props: OpenApiProps, + outputFormat: 'model' | 'form' = 'model' +): Record { const attrs: Record = {}; // expand all attributes for (const propName in props) { @@ -229,6 +259,7 @@ export const expandOpenApiProps = function (props: Record): sensitive, editType, description: displayDescription, + identifier, } = prop['x-vault-displayAttrs'] || {}; if (type === 'integer') { @@ -255,9 +286,10 @@ export const expandOpenApiProps = function (props: Record): fieldGroup: group || 'default', readOnly: isId, defaultValue: value || undefined, + identifier, }; - if (type === 'object' && !!value) { + if (type === 'object' && !!value && outputFormat !== 'form') { attrDefn.defaultValue = () => { return value; }; @@ -285,11 +317,33 @@ export const expandOpenApiProps = function (props: Record): delete attrDefn[attrProp]; } } - attrs[camelize(propName)] = attrDefn; + + const key = outputFormat === 'model' ? camelize(propName) : propName; + attrs[key] = attrDefn; } return attrs; }; +/* + * extract props for post request schema from OpenAPI help response + * returns expanded OpenAPI props to be used in forms + */ +export const propsForSchema = function (helpResponse: OpenApiHelpResponse) { + const { openapi } = helpResponse; + // paths is an array but it will have a single entry with the scope we're in + const path = Object.values(openapi.paths)[0]; + const schema = path?.post?.requestBody?.content['application/json']?.schema; + if (schema?.$ref) { + // $ref will be shaped like `#/components/schemas/MyResponseType + // this maps to the location of the item in openapi.components.schemas + const schemaRef = schema.$ref.replace('#/components/schemas/', ''); + const props = openapi.components.schemas[schemaRef]?.['properties'] as OpenApiProps; + return expandOpenApiProps(props, 'form'); + } + + return {}; +}; + /** * combineOpenApiAttrs takes attributes defined on an existing models * and adds in the attributes found on an OpenAPI response. The values diff --git a/ui/tests/acceptance/auth/enable-tune-form-test.js b/ui/tests/acceptance/auth/enable-tune-form-test.js index e025d09ca6..c19ecd5710 100644 --- a/ui/tests/acceptance/auth/enable-tune-form-test.js +++ b/ui/tests/acceptance/auth/enable-tune-form-test.js @@ -36,6 +36,29 @@ module('Acceptance | auth enable tune form test', function (hooks) { 'config.allowed_response_headers', 'config.plugin_version', ]; + this.tokensGroup = { + Tokens: [ + 'token_bound_cidrs', + 'token_explicit_max_ttl', + 'token_max_ttl', + 'token_no_default_policy', + 'token_num_uses', + 'token_period', + 'token_policies', + 'token_ttl', + 'token_type', + ], + }; + this.oidcJwtGroup = { + 'OIDC/JWT Options': [ + 'oidc_client_id', + 'oidc_client_secret', + 'oidc_discovery_ca_pem', + 'jwt_validation_pubkeys', + 'jwt_supported_algs', + 'bound_issuer', + ], + }; }); module('azure', function (hooks) { @@ -44,16 +67,19 @@ module('Acceptance | auth enable tune form test', function (hooks) { this.path = `${this.type}-${uuidv4()}`; this.tuneFields = [ 'environment', - 'identityTokenAudience', - 'identityTokenTtl', - 'maxRetries', - 'maxRetryDelay', + 'identity_token_audience', + 'identity_token_ttl', + 'max_retries', + 'max_retry_delay', 'resource', - 'retryDelay', - 'rootPasswordTtl', - 'tenantId', + 'retry_delay', + 'root_password_ttl', + 'tenant_id', ]; - this.tuneToggles = { 'Azure Options': ['clientId', 'clientSecret'] }; + // until the vault-plugin-auth-azure changes are released, these fields will be in the default group + // this test should then fail and the following line can be removed and the next line uncommented + this.tuneFields.push('client_id', 'client_secret'); + // this.tuneToggles = { 'Azure Options': ['client_id', 'client_secret'] }; await login(); return visit('/vault/settings/auth/enable'); }); @@ -68,29 +94,25 @@ module('Acceptance | auth enable tune form test', function (hooks) { this.type = 'jwt'; this.path = `${this.type}-${uuidv4()}`; this.customSelectors = { - providerConfig: `${GENERAL.fieldByAttr('providerConfig')} .cm-editor`, + provider_config: `${GENERAL.fieldByAttr('provider_config')} .cm-editor`, }; this.tuneFields = [ - 'defaultRole', - 'jwksCaPem', - 'jwksUrl', - 'namespaceInState', - 'oidcDiscoveryUrl', - 'oidcResponseMode', - 'oidcResponseTypes', - 'providerConfig', - 'unsupportedCriticalCertExtensions', + 'default_role', + 'jwks_ca_pem', + 'jwks_url', + 'namespace_in_state', + 'oidc_discovery_url', + 'oidc_response_mode', + 'oidc_response_types', + // provider_config will be updated to EditType: file in next version of vault-plugin-auth-jwt + // commenting out for now to avoid test failure + // 'provider_config', + 'unsupported_critical_cert_extensions', ]; - this.tuneToggles = { - 'JWT Options': [ - 'oidcClientId', - 'oidcClientSecret', - 'oidcDiscoveryCaPem', - 'jwtValidationPubkeys', - 'jwtSupportedAlgs', - 'boundIssuer', - ], - }; + // until the vault-plugin-auth-jwt changes are released, these fields will be in the default group + // this test should then fail and the following line can be removed and the next line uncommented + this.tuneFields.push(...this.oidcJwtGroup['OIDC/JWT Options']); + // this.tuneToggles = this.oidcJwtGroup; await login(); return visit('/vault/settings/auth/enable'); }); @@ -106,41 +128,33 @@ module('Acceptance | auth enable tune form test', function (hooks) { this.path = `${this.type}-${uuidv4()}`; this.tuneFields = [ 'url', - 'caseSensitiveNames', - 'connectionTimeout', - 'dereferenceAliases', - 'maxPageSize', - 'passwordPolicy', - 'requestTimeout', - 'tokenBoundCidrs', - 'tokenExplicitMaxTtl', - 'tokenMaxTtl', - 'tokenNoDefaultPolicy', - 'tokenNumUses', - 'tokenPeriod', - 'tokenPolicies', - 'tokenTtl', - 'tokenType', - 'usePre111GroupCnBehavior', - 'usernameAsAlias', + 'case_sensitive_names', + 'connection_timeout', + 'dereference_aliases', + 'max_page_size', + 'password_policy', + 'request_timeout', + 'use_pre111_group_cn_behavior', + 'username_as_alias', ]; this.tuneToggles = { 'LDAP Options': [ 'starttls', - 'insecureTls', + 'insecure_tls', 'discoverdn', - 'denyNullBind', - 'tlsMinVersion', - 'tlsMaxVersion', + 'deny_null_bind', + 'tls_min_version', + 'tls_max_version', 'certificate', - 'clientTlsCert', - 'clientTlsKey', + 'client_tls_cert', + 'client_tls_key', 'userattr', 'upndomain', - 'anonymousGroupSearch', + 'anonymous_group_search', ], 'Customize User Search': ['binddn', 'userdn', 'bindpass', 'userfilter'], - 'Customize Group Membership Search': ['groupfilter', 'groupattr', 'groupdn', 'useTokenGroups'], + 'Customize Group Membership Search': ['groupfilter', 'groupattr', 'groupdn', 'use_token_groups'], + ...this.tokensGroup, }; await login(); return visit('/vault/settings/auth/enable'); @@ -156,29 +170,23 @@ module('Acceptance | auth enable tune form test', function (hooks) { this.type = 'oidc'; this.path = `${this.type}-${uuidv4()}`; this.customSelectors = { - providerConfig: `${GENERAL.fieldByAttr('providerConfig')} .cm-editor`, + provider_config: `${GENERAL.fieldByAttr('provider_config')} .cm-editor`, }; this.tuneFields = [ - 'oidcDiscoveryUrl', - 'defaultRole', - 'jwksCaPem', - 'jwksUrl', - 'oidcResponseMode', - 'oidcResponseTypes', - 'namespaceInState', - 'providerConfig', - 'unsupportedCriticalCertExtensions', + 'oidc_discovery_url', + 'default_role', + 'jwks_ca_pem', + 'jwks_url', + 'oidc_response_mode', + 'oidc_response_types', + 'namespace_in_state', + 'provider_config', + 'unsupported_critical_cert_extensions', ]; - this.tuneToggles = { - 'OIDC Options': [ - 'oidcClientId', - 'oidcClientSecret', - 'oidcDiscoveryCaPem', - 'jwtValidationPubkeys', - 'jwtSupportedAlgs', - 'boundIssuer', - ], - }; + // until the vault-plugin-auth-jwt changes are released, these fields will be in the default group + // this test should then fail and the following line can be removed and the next line uncommented + this.tuneFields.push(...this.oidcJwtGroup['OIDC/JWT Options']); + // this.tuneToggles = this.oidcJwtGroup; await login(); return visit('/vault/settings/auth/enable'); }); @@ -192,19 +200,8 @@ module('Acceptance | auth enable tune form test', function (hooks) { hooks.beforeEach(async function () { this.type = 'okta'; this.path = `${this.type}-${uuidv4()}`; - this.tuneFields = [ - 'orgName', - 'tokenBoundCidrs', - 'tokenExplicitMaxTtl', - 'tokenMaxTtl', - 'tokenNoDefaultPolicy', - 'tokenNumUses', - 'tokenPeriod', - 'tokenPolicies', - 'tokenTtl', - 'tokenType', - ]; - this.tuneToggles = { Options: ['apiToken', 'baseUrl', 'bypassOktaMfa'] }; + this.tuneFields = ['org_name', 'api_token', 'base_url', 'bypass_okta_mfa']; + this.tuneToggles = this.tokensGroup; await login(); return visit('/vault/settings/auth/enable'); }); diff --git a/ui/tests/acceptance/settings/auth/configure/section-test.js b/ui/tests/acceptance/settings/auth/configure/section-test.js index 9217d716f5..cb116605e6 100644 --- a/ui/tests/acceptance/settings/auth/configure/section-test.js +++ b/ui/tests/acceptance/settings/auth/configure/section-test.js @@ -31,7 +31,12 @@ module('Acceptance | settings/auth/configure/section', function (hooks) { test('it can save options', async function (assert) { assert.expect(6); - this.server.post(`/sys/mounts/auth/:path/tune`, function (schema, request) { + + const path = `approle-save-${this.uid}`; + const type = 'approle'; + const section = 'options'; + + this.server.post(`/sys/mounts/auth/${path}/tune`, function (schema, request) { const body = JSON.parse(request.requestBody); const keys = Object.keys(body); assert.strictEqual(body.token_type, 'batch', 'passes new token type'); @@ -40,17 +45,16 @@ module('Acceptance | settings/auth/configure/section', function (hooks) { assert.true(keys.includes('description'), 'passes updated description on tune'); return request.passthrough(); }); - const path = `approle-save-${this.uid}`; - const type = 'approle'; - const section = 'options'; + await enablePage.enable(type, path); await page.visit({ path, section }); await fillIn(GENERAL.inputByAttr('description'), 'This is Approle!'); assert - .dom(GENERAL.inputByAttr('config.tokenType')) + .dom(GENERAL.inputByAttr('config.token_type')) .hasValue('default-service', 'as default the token type selected is default-service.'); - await fillIn(GENERAL.inputByAttr('config.tokenType'), 'batch'); + await fillIn(GENERAL.inputByAttr('config.token_type'), 'batch'); await click(GENERAL.submitButton); + assert.strictEqual( page.flash.latestMessage, `The configuration was saved successfully.`, diff --git a/ui/tests/integration/components/auth-config-form/options-test.js b/ui/tests/integration/components/auth-config-form/options-test.js index 5a2a6d85c3..09918aa49c 100644 --- a/ui/tests/integration/components/auth-config-form/options-test.js +++ b/ui/tests/integration/components/auth-config-form/options-test.js @@ -10,6 +10,8 @@ import { click, fillIn, render } from '@ember/test-helpers'; import hbs from 'htmlbars-inline-precompile'; import { GENERAL } from 'vault/tests/helpers/general-selectors'; import { filterEnginesByMountCategory } from 'vault/utils/all-engines-metadata'; +import AuthMethodForm from 'vault/forms/auth/method'; +import sinon from 'sinon'; const userLockoutSupported = ['approle', 'ldap', 'userpass']; const userLockoutUnsupported = filterEnginesByMountCategory({ mountCategory: 'auth', isEnterprise: false }) @@ -23,28 +25,26 @@ module('Integration | Component | auth-config-form options', function (hooks) { hooks.beforeEach(function () { this.owner.lookup('service:flash-messages').registerTypes(['success']); this.router = this.owner.lookup('service:router'); - this.store = this.owner.lookup('service:store'); - this.createModel = (path, type) => { - this.model = this.store.createRecord('auth-method', { path, type }); - this.model.set('config', this.store.createRecord('mount-config')); + this.transitionStub = sinon + .stub(this.router, 'transitionTo') + .returns({ followRedirects: () => Promise.resolve() }); + + this.renderComponent = (path, type) => { + this.form = new AuthMethodForm({ + path, + config: { listing_visibility: false }, + user_lockout_config: {}, + }); + this.form.type = type; + return render(hbs``); }; }); for (const type of userLockoutSupported) { test(`it submits data correctly for ${type} method (supports user_lockout_config)`, async function (assert) { assert.expect(3); - const path = `my-${type}-auth/`; - this.createModel(path, type); - this.router.reopen({ - transitionTo() { - return { - followRedirects() { - assert.ok(true, `saving ${type} calls transitionTo on save`); - }, - }; - }, - }); + const path = `my-${type}-auth`; this.server.post(`sys/mounts/auth/${path}/tune`, (schema, req) => { const payload = JSON.parse(req.requestBody); @@ -62,30 +62,36 @@ module('Integration | Component | auth-config-form options', function (hooks) { assert.propEqual(payload, expected, `${type} method payload contains tune parameters`); return { payload }; }); - await render(hbs``); + + await this.renderComponent(path, type); assert.dom('[data-test-user-lockout-section]').hasText('User lockout configuration'); - await click(GENERAL.toggleInput('toggle-config.listingVisibility')); - await fillIn(GENERAL.inputByAttr('config.tokenType'), 'default-batch'); + await click(GENERAL.toggleInput('toggle-config.listing_visibility')); + await fillIn(GENERAL.inputByAttr('config.token_type'), 'default-batch'); await click(GENERAL.ttl.toggle('Default Lease TTL')); await fillIn(GENERAL.ttl.input('Default Lease TTL'), '30'); - await fillIn(GENERAL.inputByAttr('config.lockoutThreshold'), '7'); + await fillIn(GENERAL.inputByAttr('user_lockout_config.lockout_threshold'), '7'); await click(GENERAL.ttl.toggle('Lockout duration')); await fillIn(GENERAL.ttl.input('Lockout duration'), '10'); await fillIn( - `${GENERAL.inputByAttr('config.lockoutDuration')} ${GENERAL.selectByAttr('ttl-unit')}`, + `${GENERAL.inputByAttr('user_lockout_config.lockout_duration')} ${GENERAL.selectByAttr('ttl-unit')}`, 'm' ); await click(GENERAL.ttl.toggle('Lockout counter reset')); await fillIn(GENERAL.ttl.input('Lockout counter reset'), '5'); - await click(GENERAL.inputByAttr('config.lockoutDisable')); + await click(GENERAL.inputByAttr('user_lockout_config.lockout_disable')); await click(GENERAL.submitButton); + + assert.true( + this.transitionStub.calledWith('vault.cluster.access.methods'), + 'transitions to access methods list on save' + ); }); } @@ -95,18 +101,7 @@ module('Integration | Component | auth-config-form options', function (hooks) { test(`it submits data correctly for ${type} auth method`, async function (assert) { assert.expect(7); - const path = `my-${type}-auth/`; - this.createModel(path, type); - - this.router.reopen({ - transitionTo() { - return { - followRedirects() { - assert.ok(true, `saving ${type} calls transitionTo on save`); - }, - }; - }, - }); + const path = `my-${type}-auth`; this.server.post(`sys/mounts/auth/${path}/tune`, (schema, req) => { const payload = JSON.parse(req.requestBody); @@ -118,20 +113,21 @@ module('Integration | Component | auth-config-form options', function (hooks) { assert.propEqual(payload, expected, `${type} method payload contains tune parameters`); return { payload }; }); - await render(hbs``); + + await this.renderComponent(path, type); assert .dom('[data-test-user-lockout-section]') .doesNotExist(`${type} method does not render user lockout section`); - await click(GENERAL.toggleInput('toggle-config.listingVisibility')); - await fillIn(GENERAL.inputByAttr('config.tokenType'), 'default-batch'); + await click(GENERAL.toggleInput('toggle-config.listing_visibility')); + await fillIn(GENERAL.inputByAttr('config.token_type'), 'default-batch'); await click(GENERAL.ttl.toggle('Default Lease TTL')); await fillIn(GENERAL.ttl.input('Default Lease TTL'), '30'); assert - .dom(GENERAL.inputByAttr('config.lockoutThreshold')) + .dom(GENERAL.inputByAttr('user_lockout_config.lockout_threshold')) .doesNotExist(`${type} method does not render lockout threshold`); assert .dom(GENERAL.ttl.toggle('Lockout duration')) @@ -140,28 +136,23 @@ module('Integration | Component | auth-config-form options', function (hooks) { .dom(GENERAL.ttl.toggle('Lockout counter reset')) .doesNotExist(`${type} method does not render lockout counter reset`); assert - .dom(GENERAL.inputByAttr('config.lockoutDisable')) + .dom(GENERAL.inputByAttr('user_lockout_config.lockout_disable')) .doesNotExist(`${type} method does not render lockout disable`); await click(GENERAL.submitButton); + + assert.true( + this.transitionStub.calledWith('vault.cluster.access.methods'), + 'transitions to access methods list on save' + ); }); } test('it submits data correctly for token auth method', async function (assert) { assert.expect(8); - const type = 'token'; - const path = `my-${type}-auth/`; - this.createModel(path, type); - this.router.reopen({ - transitionTo() { - return { - followRedirects() { - assert.ok(true, `saving token calls transitionTo on save`); - }, - }; - }, - }); + const type = 'token'; + const path = `my-${type}-auth`; this.server.post(`sys/mounts/auth/${path}/tune`, (schema, req) => { const payload = JSON.parse(req.requestBody); @@ -172,19 +163,20 @@ module('Integration | Component | auth-config-form options', function (hooks) { assert.propEqual(payload, expected, `${type} method payload contains tune parameters`); return { payload }; }); - await render(hbs``); + + await this.renderComponent(path, type); assert - .dom(GENERAL.inputByAttr('config.tokenType')) - .doesNotExist('does not render tokenType for token auth method'); + .dom(GENERAL.inputByAttr('config.token_type')) + .doesNotExist('does not render token_type for token auth method'); - await click(GENERAL.toggleInput('toggle-config.listingVisibility')); + await click(GENERAL.toggleInput('toggle-config.listing_visibility')); await click(GENERAL.ttl.toggle('Default Lease TTL')); await fillIn(GENERAL.ttl.input('Default Lease TTL'), '30'); assert.dom('[data-test-user-lockout-section]').doesNotExist('token does not render user lockout section'); assert - .dom(GENERAL.inputByAttr('config.lockoutThreshold')) + .dom(GENERAL.inputByAttr('user_lockout_config.lockout_threshold')) .doesNotExist('token method does not render lockout threshold'); assert .dom(GENERAL.ttl.toggle('Lockout duration')) @@ -193,9 +185,14 @@ module('Integration | Component | auth-config-form options', function (hooks) { .dom(GENERAL.ttl.toggle('Lockout counter reset')) .doesNotExist('token method does not render lockout counter reset'); assert - .dom(GENERAL.inputByAttr('config.lockoutDisable')) + .dom(GENERAL.inputByAttr('user_lockout_config.lockout_disable')) .doesNotExist('token method does not render lockout disable'); await click(GENERAL.submitButton); + + assert.true( + this.transitionStub.calledWith('vault.cluster.access.methods'), + 'transitions to access methods list on save' + ); }); }); diff --git a/ui/types/vault/auth/methods.d.ts b/ui/types/vault/auth/methods.d.ts index 78db541fd4..f0acf94918 100644 --- a/ui/types/vault/auth/methods.d.ts +++ b/ui/types/vault/auth/methods.d.ts @@ -87,7 +87,15 @@ export interface UsernameLoginResponse extends ApiResponse { }; } +export type UserLockoutConfig = { + lockout_threshold?: string; + lockout_duration?: string; + lockout_counter_reset?: string; + lockout_disable?: boolean; +}; + export type AuthMethodFormData = AuthEnableMethodRequest & { path: string; config: MountConfig; + user_lockout_config: UserLockoutConfig; }; diff --git a/ui/types/vault/services/path-help.d.ts b/ui/types/vault/services/path-help.d.ts index 6ce020ccc0..31d6f655aa 100644 --- a/ui/types/vault/services/path-help.d.ts +++ b/ui/types/vault/services/path-help.d.ts @@ -9,4 +9,6 @@ import type { PathInfo } from 'vault/utils/openapi-helpers'; export default class PathHelpService extends Service { getPaths(apiPath: string, backend: string, itemType?: string, itemID?: string): Promise; + hydrateModel(modelType: string, backend: string): Promise; + getNewModel(modelType: string, backend: string, apiPath: string, itemType?: string): Promise; } diff --git a/ui/yarn.lock b/ui/yarn.lock index eab7f09ed9..e83b3e653c 100644 --- a/ui/yarn.lock +++ b/ui/yarn.lock @@ -2783,8 +2783,8 @@ __metadata: "@hashicorp/vault-client-typescript@hashicorp/vault-client-typescript": version: 0.0.0 - resolution: "@hashicorp/vault-client-typescript@https://github.com/hashicorp/vault-client-typescript.git#commit=20c8c9bb516615b32bc1ab7edd890e0fbbfcae4e" - checksum: e78c7f9c195290d8e7d4b730fb1353124cdfba88500aa95f196e682c49bc88a7c8a98784b4eba81447d9456a9c12396a6c3e829fea42f0dbff217640d33f870c + resolution: "@hashicorp/vault-client-typescript@https://github.com/hashicorp/vault-client-typescript.git#commit=159865331f4ff0219264ab243b2e30a0087f972f" + checksum: ae044d2a927c2d351330690ecedb36a6321106ce9383a77495f03a785e39bfd469a385274ef3248c21b5e5540da975297dd42949e66c5ce31b090128b822bce7 languageName: node linkType: hard