/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import Component from '@glimmer/component';
import { action } from '@ember/object';
import { task } from 'ember-concurrency';
import { waitFor } from '@ember/test-waiters';
import { service } from '@ember/service';
import { tracked } from '@glimmer/tracking';
import { ValidationMap } from 'vault/vault/app-types';
import { assert } from '@ember/debug';
import { capitalize } from '@ember/string';
import errorMessage from 'vault/utils/error-message';
import type MountConfigModel from 'vault/vault/models/secret-engine/mount-config';
import type AdditionalConfigModel from 'vault/vault/models/secret-engine/additional-config';
import type IdentityOidcConfigModel from 'vault/models/identity/oidc/config';
import type Router from '@ember/routing/router';
import type Store from '@ember-data/store';
import type VersionService from 'vault/services/version';
import type FlashMessageService from 'vault/services/flash-messages';
/**
* @module SecretEngineConfigureWif component is used to configure secret engines that allow the WIF (Workload Identity Federation) configuration.
* The ability to configure WIF fields is an enterprise only feature.
* If the user is configuring WIF attributes they will also have the option to update the global issuer config, which is a separate endpoint named identity/oidc/config.
* If a user is on CE, the account configuration fields will display with no ability to select or see wif fields.
*
* @example
*
*
* @param {string} backendPath - name of the secret engine, ex: 'azure-123'.
* @param {string} displayName - used for flash messages, subText and labels. ex: 'Azure'.
* @param {string} type - the type of the engine, ex: 'azure'.
* @param {object} mountConfigModel - the config model for the engine. The attr `isWifPluginConfigured` must be added to this config model otherwise this component will assert an error. `isWifPluginConfigured` should return true if any required wif fields have been set.
* @param {object} [additionalConfigModel] - for engines with two config models. Currently, only used by aws
* @param {object} [issuerConfig] - the identity/oidc/config model. Will be passed in if user has an enterprise license.
*/
interface Args {
backendPath: string;
displayName: string;
type: string;
mountConfigModel: MountConfigModel;
additionalConfigModel: AdditionalConfigModel;
issuerConfig: IdentityOidcConfigModel;
}
export default class ConfigureWif extends Component {
@service declare readonly router: Router;
@service declare readonly store: Store;
@service declare readonly version: VersionService;
@service declare readonly flashMessages: FlashMessageService;
@tracked accessType = 'account'; // for community users they will not be able to change this. for enterprise users, they will have the option to select "wif".
@tracked errorMessage = '';
@tracked invalidFormAlert = '';
@tracked saveIssuerWarning = '';
@tracked modelValidations: ValidationMap | null = null;
disableAccessType = false;
constructor(owner: unknown, args: Args) {
super(owner, args);
// the following checks are only relevant to existing enterprise configurations
if (this.version.isCommunity && this.args.mountConfigModel.isNew) return;
const { isWifPluginConfigured, isAccountPluginConfigured } = this.args.mountConfigModel;
assert(
`'isWifPluginConfigured' is required to be defined on the config model. Must return a boolean.`,
isWifPluginConfigured !== undefined
);
this.accessType = isWifPluginConfigured ? 'wif' : 'account';
// if wif or account only attributes are defined, disable the user's ability to change the access type
this.disableAccessType = isWifPluginConfigured || isAccountPluginConfigured;
}
get mountConfigModelAttrChanged() {
// "backend" dirties model state so explicity ignore it here
return Object.keys(this.args.mountConfigModel?.changedAttributes()).some((item) => item !== 'backend');
}
get issuerAttrChanged() {
return this.args.issuerConfig?.hasDirtyAttributes;
}
get additionalConfigModelAttrChanged() {
const { additionalConfigModel } = this.args;
// required to check for additional model otherwise Object.keys will have nothing to iterate over and fails
return additionalConfigModel
? Object.keys(additionalConfigModel.changedAttributes()).some((item) => item !== 'backend')
: false;
}
@action continueSubmitForm() {
this.saveIssuerWarning = '';
this.save.perform();
}
// check if the issuer has been changed to show issuer modal
// continue saving the configuration
submitForm = task(
waitFor(async (event: Event) => {
event?.preventDefault();
this.resetErrors();
// currently we only check validations on the additional model
if (this.args.additionalConfigModel && !this.isValid(this.args.additionalConfigModel)) {
return;
}
if (this.issuerAttrChanged) {
// if the issuer has changed show modal with warning that the config will change
// if the modal is shown, the user has to click confirm to continue saving
this.saveIssuerWarning = `You are updating the global issuer config. This will overwrite Vault's current issuer ${
this.args.issuerConfig.queryIssuerError ? 'if it exists ' : ''
}and may affect other configurations using this value. Continue?`;
// exit task until user confirms
return;
}
await this.save.perform();
})
);
save = task(
waitFor(async () => {
const mountConfigModelChanged = this.mountConfigModelAttrChanged;
const additionalModelAttrChanged = this.additionalConfigModelAttrChanged;
const issuerAttrChanged = this.issuerAttrChanged;
// check if any of the model(s) or issuer attributes have changed
// if no changes, transition and notify user
if (!mountConfigModelChanged && !additionalModelAttrChanged && !issuerAttrChanged) {
this.flashMessages.info('No changes detected.');
this.transition();
return;
}
const mountConfigModelSaved = mountConfigModelChanged ? await this.saveMountConfigModel() : false;
const issuerSaved = issuerAttrChanged ? await this.updateIssuer() : false;
if (
mountConfigModelSaved ||
(!mountConfigModelChanged && issuerSaved) ||
(!mountConfigModelChanged && additionalModelAttrChanged)
) {
// if there are changes made to the an additional model, attempt to save it. if saving fails, we transition and the failure will surface as a sticky flash message on the configuration details page.
if (additionalModelAttrChanged) {
await this.saveAdditionalConfigModel();
}
// we only prevent a transition if the mount config model or issuer fail when saving
this.transition();
} else {
return;
}
})
);
async updateIssuer(): Promise {
try {
await this.args.issuerConfig.save();
this.flashMessages.success('Issuer saved successfully');
return true;
} catch (e) {
this.flashMessages.danger(`Issuer was not saved: ${errorMessage(e, 'Check Vault logs for details.')}`);
// remove issuer from the config model if it was not saved
this.args.issuerConfig.rollbackAttributes();
return false;
}
}
async saveMountConfigModel(): Promise {
const { backendPath, mountConfigModel } = this.args;
try {
await mountConfigModel.save();
this.flashMessages.success(`Successfully saved ${backendPath}'s configuration.`);
return true;
} catch (error) {
this.errorMessage = errorMessage(error);
this.invalidFormAlert = 'There was an error submitting this form.';
return false;
}
}
async saveAdditionalConfigModel() {
const { backendPath, additionalConfigModel, type } = this.args;
const additionalConfigModelName = type === 'aws' ? 'lease configuration' : 'additional configuration';
try {
await additionalConfigModel.save();
this.flashMessages.success(`Successfully saved ${backendPath}'s ${additionalConfigModelName}.`);
} catch (error) {
// the only error the user sees is a sticky flash message on the next view.
this.flashMessages.danger(
`${capitalize(additionalConfigModelName)} was not saved: ${errorMessage(error)}`,
{
sticky: true,
}
);
}
}
resetErrors() {
this.flashMessages.clearMessages();
this.errorMessage = this.invalidFormAlert = '';
this.modelValidations = null;
}
transition() {
this.router.transitionTo('vault.cluster.secrets.backend.configuration', this.args.backendPath);
}
isValid(model: AdditionalConfigModel) {
const { isValid, state, invalidFormMessage } = model.validate();
this.modelValidations = isValid ? null : state;
this.invalidFormAlert = isValid ? '' : invalidFormMessage;
return isValid;
}
@action
onChangeAccessType(accessType: string) {
this.accessType = accessType;
const { mountConfigModel, type } = this.args;
if (accessType === 'account') {
// reset all "wif" attributes that are mutually exclusive with "account" attributes
// these attributes are the same for each engine
mountConfigModel.identityTokenAudience = mountConfigModel.identityTokenTtl = undefined;
// return the issuer to the globally set value (if there is one) on toggle
this.args.issuerConfig.rollbackAttributes();
}
if (accessType === 'wif') {
// reset all "account" attributes that are mutually exclusive with "wif" attributes
// these attributes are different for each engine
type === 'azure'
? (mountConfigModel.clientSecret = undefined)
: type === 'aws'
? (mountConfigModel.accessKey = undefined)
: null;
}
}
@action
onCancel() {
this.resetErrors();
this.args.mountConfigModel.unloadRecord();
this.transition();
}
}