One WIF configuration component (#29367)

* make one component and make one test file for that component. remove the two components and associated files the new component replaces

* make access type subtext dynamic based on model type

* clean up

* clean up

* remove model attr for display purposes

* split out lease to another second config model type and make is-wif-engine helper

* welp missed the old controller

* small removal of overkill comment

* pr feedback

* save lease config if only thing changed

* error handling in acceptance test

* test fix

* replace notOk with throw

* move back error message

* clean up focused largely on wif component test

* replace ok with true
This commit is contained in:
Angel Garbarino
2025-01-24 11:05:00 -07:00
committed by GitHub
parent 8d83c5d047
commit 088bb4b6b9
22 changed files with 1487 additions and 1831 deletions

View File

@@ -1,136 +0,0 @@
{{!
Copyright (c) HashiCorp, Inc.
SPDX-License-Identifier: BUSL-1.1
~}}
<form {{on "submit" (perform this.submitForm)}} aria-label="save aws creds" data-test-root-form>
<div class="box is-fullwidth is-shadowless is-marginless">
<NamespaceReminder @mode="save" @noun="configuration" />
<MessageError @errorMessage={{this.errorMessageRoot}} />
<p class="has-text-grey-dark">
Note: the client uses the official AWS SDK and will use the specified credentials, environment credentials, shared file
credentials, or IAM role/ECS task credentials in that order.
</p>
</div>
{{! Root configuration details }}
<h2 class="title is-5 has-bottom-padding-s has-top-margin-l" data-test-access-title>
Access to AWS
</h2>
<div class="box is-fullwidth is-sideless">
{{! WIF is an enterprise only feature. We default to IAM access type for community users and display only those related form fields. }}
{{#if this.version.isEnterprise}}
<fieldset class="field form-fieldset" id="protection" data-test-access-type-section>
<legend class="is-label">Access Type</legend>
<p class="sub-text" data-test-access-type-subtext>
{{#if this.disableAccessType}}
You cannot edit Access Type if you have already saved access credentials.
{{else}}
Choose the way to configure access to AWS. Access can be configured either with IAM access keys, or using Plugin
Workload Identity Federation (WIF).{{/if}}</p>
<div>
<RadioButton
id="access-type-iam"
name="iam"
class="radio"
data-test-access-type="iam"
@value="iam"
@groupValue={{this.accessType}}
@onChange={{fn this.onChangeAccessType "iam"}}
@disabled={{this.disableAccessType}}
/>
<label for="access-type-iam">IAM Credentials</label>
<RadioButton
id="access-type-wif"
name="wif"
class="radio has-left-margin-m"
data-test-access-type="wif"
@value="wif"
@groupValue={{this.accessType}}
@onChange={{fn this.onChangeAccessType "wif"}}
@disabled={{this.disableAccessType}}
/>
<label for="access-type-wif">Workload Identity Federation</label>
</div>
</fieldset>
{{/if}}
{{#if (eq this.accessType "wif")}}
{{! WIF Fields }}
{{#each @issuerConfig.displayAttrs as |attr|}}
<FormField @attr={{attr}} @model={{@issuerConfig}} />
{{/each}}
<FormFieldGroups
@model={{@rootConfig}}
@mode={{if @rootConfig.isNew "create" "edit"}}
@modelValidations={{this.modelValidationsRoot}}
@groupName="fieldGroupsWif"
/>
{{else}}
{{! IAM Fields }}
<FormFieldGroups
@model={{@rootConfig}}
@mode={{if @rootConfig.isNew "create" "edit"}}
@modelValidations={{this.modelValidationsRoot}}
@useEnableInput={{true}}
@groupName="fieldGroupsIam"
/>
{{/if}}
</div>
{{! Lease configuration details }}
<h2 class="title is-5 has-bottom-padding-s has-top-margin-l" data-test-lease-title>
Leases
</h2>
<div class="box is-fullwidth is-sideless is-bottomless">
{{#each @leaseConfig.displayAttrs as |attr|}}
<FormField @attr={{attr}} @model={{@leaseConfig}} @modelValidations={{this.modelValidationsLease}} />
{{/each}}
</div>
<div class="box is-fullwidth is-bottomless">
<div class="control">
<Hds::Button
@text="Save"
@icon={{if this.save.isRunning "loading"}}
type="submit"
disabled={{this.save.isRunning}}
data-test-save
/>
<Hds::Button
@text="Cancel"
@color="secondary"
class="has-left-margin-s"
disabled={{this.save.isRunning}}
{{on "click" this.onCancel}}
data-test-cancel
/>
</div>
{{#if this.invalidFormAlert}}
<AlertInline
data-test-invalid-form-alert
class="has-top-padding-s"
@type="danger"
@message={{this.invalidFormAlert}}
/>
{{/if}}
</div>
</form>
{{#if this.saveIssuerWarning}}
<Hds::Modal @color="warning" @onClose={{action (mut this.saveIssuerWarning) ""}} data-test-issuer-warning as |M|>
<M.Header @icon="alert-circle">
Are you sure?
</M.Header>
<M.Body>
<p class="has-bottom-margin-s" data-test-issuer-warning-message>
{{this.saveIssuerWarning}}
</p>
</M.Body>
<M.Footer as |F|>
<Hds::ButtonSet>
<Hds::Button @text="Continue" {{on "click" this.continueSubmitForm}} data-test-issuer-save />
<Hds::Button @text="Cancel" @color="secondary" {{on "click" F.close}} data-test-issuer-cancel />
</Hds::ButtonSet>
</M.Footer>
</Hds::Modal>
{{/if}}

View File

@@ -1,232 +0,0 @@
/**
* 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 errorMessage from 'vault/utils/error-message';
import type LeaseConfigModel from 'vault/models/aws/lease-config';
import type RootConfigModel from 'vault/models/aws/root-config';
import type IdentityOidcConfigModel from 'vault/models/identity/oidc/config';
import type Router from '@ember/routing/router';
import type StoreService from 'vault/services/store';
import type VersionService from 'vault/services/version';
import type FlashMessageService from 'vault/services/flash-messages';
/**
* @module ConfigureAwsComponent is used to configure the AWS secret engine
* A user can configure the endpoint root/config and/or lease/config.
* For enterprise users, they will see an additional option to config WIF attributes in place of IAM attributes.
* The fields for these endpoints are on one form.
*
* @example
* ```js
* <SecretEngine::ConfigureAws
@rootConfig={{this.model.aws-root-config}}
@leaseConfig={{this.model.aws-lease-config}}
@backendPath={{this.model.id}}
/>
* ```
*
* @param {object} rootConfig - AWS config/root model
* @param {object} leaseConfig - AWS config/lease model
* @param {string} backendPath - name of the AWS secret engine, ex: 'aws-123'
*/
interface Args {
leaseConfig: LeaseConfigModel;
rootConfig: RootConfigModel;
issuerConfig: IdentityOidcConfigModel;
backendPath: string;
issuer?: string;
}
export default class ConfigureAwsComponent extends Component<Args> {
@service declare readonly router: Router;
@service declare readonly store: StoreService;
@service declare readonly version: VersionService;
@service declare readonly flashMessages: FlashMessageService;
@tracked errorMessageRoot: string | null = null;
@tracked errorMessageLease: string | null = null;
@tracked invalidFormAlert: string | null = null;
@tracked modelValidationsLease: ValidationMap | null = null;
@tracked accessType = 'iam';
@tracked saveIssuerWarning = '';
disableAccessType = false;
constructor(owner: unknown, args: Args) {
super(owner, args);
// the following checks are only relevant to enterprise users and those editing an existing root configuration.
if (this.version.isCommunity || this.args.rootConfig.isNew) return;
const { roleArn, identityTokenAudience, identityTokenTtl, accessKey } = this.args.rootConfig;
// do not include issuer in this check. Issuer is a global endpoint and can be set even if we're not editing wif attributes
const wifAttributesSet = !!roleArn || !!identityTokenAudience || !!identityTokenTtl;
const iamAttributesSet = !!accessKey;
// If any WIF attributes have been set in the rootConfig model, set accessType to 'wif'.
this.accessType = wifAttributesSet ? 'wif' : 'iam';
// If there are either WIF or IAM attributes set then disable user's ability to change accessType.
this.disableAccessType = wifAttributesSet || iamAttributesSet;
}
@action continueSubmitForm() {
// called when the user confirms they are okay with the issuer change
this.saveIssuerWarning = '';
this.save.perform();
}
// on form submit - validate inputs and check for issuer changes
submitForm = task(
waitFor(async (event: Event) => {
event?.preventDefault();
this.resetErrors();
const { leaseConfig, issuerConfig } = this.args;
// Note: only aws/lease-config model has validations
const isValid = this.validate(leaseConfig);
if (!isValid) return;
if (issuerConfig?.hasDirtyAttributes) {
// 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 save
this.saveIssuerWarning = `You are updating the global issuer config. This will overwrite Vault's current issuer ${
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 () => {
// when we get here, the models have already been validated so just continue with save
const { leaseConfig, rootConfig, issuerConfig } = this.args;
// Check if any of the models' attributes have changed.
// If no changes to either model, transition and notify user.
// If changes to either model, save the model(s) that changed and notify user.
// Note: "backend" dirties model state so explicity ignore it here.
const leaseAttrChanged = Object.keys(leaseConfig?.changedAttributes()).some(
(item) => item !== 'backend'
);
const rootAttrChanged = Object.keys(rootConfig?.changedAttributes()).some((item) => item !== 'backend');
const issuerAttrChanged = issuerConfig?.hasDirtyAttributes;
if (!leaseAttrChanged && !rootAttrChanged && !issuerAttrChanged) {
this.flashMessages.info('No changes detected.');
this.transition();
return;
}
// Attempt saves of changed models. If at least one of them succeed, transition
const rootSaved = rootAttrChanged ? await this.saveRoot() : false;
const leaseSaved = leaseAttrChanged ? await this.saveLease() : false;
const issuerSaved = issuerAttrChanged ? await this.updateIssuer() : false;
if (rootSaved || leaseSaved || issuerSaved) {
this.transition();
} else {
// otherwise there was a failure and we should not transition and exit the function.
return;
}
})
);
async updateIssuer(): Promise<boolean> {
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.')}`);
return false;
}
}
async saveRoot(): Promise<boolean> {
const { backendPath, rootConfig } = this.args;
try {
await rootConfig.save();
this.flashMessages.success(`Successfully saved ${backendPath}'s root configuration.`);
return true;
} catch (error) {
this.errorMessageRoot = errorMessage(error);
this.invalidFormAlert = 'There was an error submitting this form.';
return false;
}
}
async saveLease(): Promise<boolean> {
const { backendPath, leaseConfig } = this.args;
try {
await leaseConfig.save();
this.flashMessages.success(`Successfully saved ${backendPath}'s lease configuration.`);
return true;
} catch (error) {
// if lease config fails, but there was no error saving rootConfig: notify user of the lease failure with a flash message, save the root config, and transition.
if (!this.errorMessageRoot) {
this.flashMessages.danger(`Lease configuration was not saved: ${errorMessage(error)}`, {
sticky: true,
});
return true;
} else {
this.errorMessageLease = errorMessage(error);
this.flashMessages.danger(
`Configuration not saved: ${errorMessage(error)}. ${this.errorMessageRoot}`
);
return false;
}
}
}
resetErrors() {
this.flashMessages.clearMessages();
this.errorMessageRoot = null;
this.invalidFormAlert = null;
}
transition() {
this.router.transitionTo('vault.cluster.secrets.backend.configuration', this.args.backendPath);
}
validate(model: LeaseConfigModel) {
const { isValid, state, invalidFormMessage } = model.validate();
this.modelValidationsLease = isValid ? null : state;
this.invalidFormAlert = isValid ? '' : invalidFormMessage;
return isValid;
}
unloadModels() {
this.args.rootConfig.unloadRecord();
this.args.leaseConfig.unloadRecord();
}
@action
onChangeAccessType(accessType: string) {
this.accessType = accessType;
const { rootConfig } = this.args;
if (accessType === 'iam') {
// reset all WIF attributes
rootConfig.roleArn = rootConfig.identityTokenAudience = rootConfig.identityTokenTtl = undefined;
// for the issuer return to the globally set value (if there is one) on toggle
this.args.issuerConfig.rollbackAttributes();
}
if (accessType === 'wif') {
// reset all IAM attributes
rootConfig.accessKey = rootConfig.secretKey = undefined;
}
}
@action
onCancel() {
// clear errors because they're canceling out of the workflow.
this.resetErrors();
this.unloadModels();
this.transition();
}
}

View File

@@ -1,184 +0,0 @@
/**
* 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 errorMessage from 'vault/utils/error-message';
import type ConfigModel from 'vault/models/azure/config';
import type IdentityOidcConfigModel from 'vault/models/identity/oidc/config';
import type Router from '@ember/routing/router';
import type StoreService from 'vault/services/store';
import type VersionService from 'vault/services/version';
import type FlashMessageService from 'vault/services/flash-messages';
/**
* @module SecretEngineConfigureAzure component is used to configure the Azure secret engine
* For enterprise users, they will see an additional option to config WIF attributes in place of Azure account attributes.
* 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.
* @example
* <SecretEngine::ConfigureAzure
@model={{this.model.azure-config}}
@backendPath={{this.model.id}}
@issuerConfig={{this.model.identity-oidc-config}}
/>
*
* @param {object} model - Azure config model
* @param {string} backendPath - name of the Azure secret engine, ex: 'azure-123'
* @param {object} issuerConfigModel - the identity/oidc/config model
*/
interface Args {
model: ConfigModel;
issuerConfig: IdentityOidcConfigModel;
backendPath: string;
}
export default class ConfigureAzureComponent extends Component<Args> {
@service declare readonly router: Router;
@service declare readonly store: StoreService;
@service declare readonly version: VersionService;
@service declare readonly flashMessages: FlashMessageService;
@tracked accessType = 'azure';
@tracked errorMessage = '';
@tracked invalidFormAlert = '';
@tracked saveIssuerWarning = '';
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.model.isNew) return;
const { isWifPluginConfigured, isAzureAccountConfigured } = this.args.model;
this.accessType = isWifPluginConfigured ? 'wif' : 'azure';
// if there are either WIF or azure attributes, disable user's ability to change accessType
this.disableAccessType = isWifPluginConfigured || isAzureAccountConfigured;
}
get modelAttrChanged() {
// "backend" dirties model state so explicity ignore it here
return Object.keys(this.args.model?.changedAttributes()).some((item) => item !== 'backend');
}
get issuerAttrChanged() {
return this.args.issuerConfig?.hasDirtyAttributes;
}
@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();
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 modelAttrChanged = this.modelAttrChanged;
const issuerAttrChanged = this.issuerAttrChanged;
// check if any of the model or issue attributes have changed
// if no changes, transition and notify user
if (!modelAttrChanged && !issuerAttrChanged) {
this.flashMessages.info('No changes detected.');
this.transition();
return;
}
const modelSaved = modelAttrChanged ? await this.saveModel() : false;
const issuerSaved = issuerAttrChanged ? await this.updateIssuer() : false;
if (modelSaved || (!modelAttrChanged && issuerSaved)) {
// transition if the model was saved successfully
// we only prevent a transition if the model is edited and fails saving
this.transition();
} else {
// otherwise there was a failure and we should not transition and exit the function
return;
}
})
);
async updateIssuer(): Promise<boolean> {
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 saveModel(): Promise<boolean> {
const { backendPath, model } = this.args;
try {
await model.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;
}
}
resetErrors() {
this.flashMessages.clearMessages();
this.errorMessage = '';
this.invalidFormAlert = '';
}
transition() {
this.router.transitionTo('vault.cluster.secrets.backend.configuration', this.args.backendPath);
}
@action
onChangeAccessType(accessType: string) {
this.accessType = accessType;
const { model } = this.args;
if (accessType === 'azure') {
// reset all WIF attributes
model.identityTokenAudience = model.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 Azure attributes
model.clientSecret = model.rootPasswordTtl = undefined;
}
}
@action
onCancel() {
this.resetErrors();
this.args.model.unloadRecord();
this.transition();
}
}

View File

@@ -3,11 +3,21 @@
SPDX-License-Identifier: BUSL-1.1
~}}
<form {{on "submit" (perform this.submitForm)}} aria-label="configure azure credentials" data-test-configure-form>
<form {{on "submit" (perform this.submitForm)}} aria-label="configure {{@type}} credentials" data-test-configure-form>
<NamespaceReminder @mode="save" @noun="configuration" />
<MessageError @errorMessage={{this.errorMessage}} />
{{! AWS specific note and section header }}
{{#if (eq @type "aws")}}
<p class="has-text-grey-dark has-top-bottom-margin" data-test-configure-note={{@type}}>
Note: the client uses the official AWS SDK and will use the specified credentials, environment credentials, shared file
credentials, or IAM role/ECS task credentials in that order.
</p>
<h2 class="title is-5 has-bottom-padding-s has-top-margin-l" data-test-access-title>
Access to AWS
</h2>
{{/if}}
<div class="box is-fullwidth is-sideless">
{{! accessType can be "azure" or "wif" - since WIF is an enterprise only feature we default to "azure" for community users and only display those related form fields. }}
{{! Only enterprise users can change access type from "account" to "wif" }}
{{#if this.version.isEnterprise}}
<fieldset class="field form-fieldset" id="protection" data-test-access-type-section>
<legend class="is-label">Access Type</legend>
@@ -15,22 +25,24 @@
{{#if this.disableAccessType}}
You cannot edit Access Type if you have already saved access credentials.
{{else}}
Choose the way to configure access to Azure. Access can be configured either using an Azure account or with the
Plugin Workload Identity Federation (WIF).
Choose the way to configure access to
{{@displayName}}. Access can be configured either using
{{if (eq @type "aws") "IAM access keys" (concat @displayName " account credentials")}}
or with the Plugin Workload Identity Federation (WIF).
{{/if}}
</p>
<div>
<RadioButton
id="access-type-azure"
name="azure"
id="access-type-{{@type}}"
name="account"
class="radio"
data-test-access-type="azure"
@value="azure"
data-test-access-type={{@type}}
@value="account"
@groupValue={{this.accessType}}
@onChange={{fn this.onChangeAccessType "azure"}}
@onChange={{this.onChangeAccessType}}
@disabled={{this.disableAccessType}}
/>
<label for="access-type-azure">Azure account credentials</label>
<label for="access-type-{{@type}}">{{@displayName}} account credentials</label>
<RadioButton
id="access-type-wif"
name="wif"
@@ -38,7 +50,7 @@
data-test-access-type="wif"
@value="wif"
@groupValue={{this.accessType}}
@onChange={{fn this.onChangeAccessType "wif"}}
@onChange={{this.onChangeAccessType}}
@disabled={{this.disableAccessType}}
/>
<label for="access-type-wif">Workload Identity Federation</label>
@@ -46,21 +58,38 @@
</fieldset>
{{/if}}
{{#if (eq this.accessType "wif")}}
{{! WIF Fields }}
{{! if access type is "wif" display Issuer and WIF fields }}
{{#each @issuerConfig.displayAttrs as |attr|}}
<FormField @attr={{attr}} @model={{@issuerConfig}} />
{{/each}}
<FormFieldGroups @model={{@model}} @mode={{if @model.isNew "create" "edit"}} @groupName="fieldGroupsWif" />
{{else}}
{{! Azure Account Fields }}
<FormFieldGroups
@model={{@model}}
@mode={{if @model.isNew "create" "edit"}}
@model={{@mountConfigModel}}
@mode={{if @mountConfigModel.isNew "create" "edit"}}
@groupName="fieldGroupsWif"
/>
{{else}}
{{! otherwise display account credential fields }}
<FormFieldGroups
@model={{@mountConfigModel}}
@mode={{if @mountConfigModel.isNew "create" "edit"}}
@useEnableInput={{true}}
@groupName="fieldGroupsAzure"
@groupName="fieldGroupsAccount"
/>
{{/if}}
{{! additionalConfigModel fields show regardless of the vault version or what access type is selected }}
{{#if @additionalConfigModel}}
<h2 class="title is-5 has-bottom-padding-s has-top-margin-l" data-test-additional-config-model-title>
{{if (eq @type "aws") "Lease" "Additional"}}
Configuration
</h2>
<div class="box is-fullwidth is-sideless is-bottomless">
{{#each @additionalConfigModel.formFields as |attr|}}
<FormField @attr={{attr}} @model={{@additionalConfigModel}} @modelValidations={{this.modelValidations}} />
{{/each}}
</div>
{{/if}}
</div>
<Hds::ButtonSet>
<Hds::Button
@text="Save"

View File

@@ -0,0 +1,252 @@
/**
* 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 StoreService from 'vault/services/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
* <SecretEngine::ConfigureWif
@backendPath={{this.model.id}}
@displayName="AWS"
@type="aws"
@mountConfigModel={{this.model.mount-config-model}}
@additionalConfigModel={{this.model.additional-config-model}}
@issuerConfig={{this.model.identity-oidc-config}}
/>
*
* @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<Args> {
@service declare readonly router: Router;
@service declare readonly store: StoreService;
@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<boolean> {
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<boolean> {
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 = mountConfigModel.rootPasswordTtl = undefined)
: type === 'aws'
? (mountConfigModel.accessKey = undefined)
: null;
}
}
@action
onCancel() {
this.resetErrors();
this.args.mountConfigModel.unloadRecord();
this.transition();
}
}

View File

@@ -3,54 +3,14 @@
* SPDX-License-Identifier: BUSL-1.1
*/
import { isPresent } from '@ember/utils';
import { service } from '@ember/service';
import Controller from '@ember/controller';
import { WIF_ENGINES, allEngines } from 'vault/helpers/mountable-secret-engines';
const CONFIG_ATTRS = {
// ssh
configured: false,
// aws root config
iamEndpoint: null,
stsEndpoint: null,
accessKey: null,
secretKey: null,
region: '',
};
export default Controller.extend(CONFIG_ATTRS, {
queryParams: ['tab'],
tab: '',
flashMessages: service(),
loading: false,
reset() {
this.model.rollbackAttributes();
this.setProperties(CONFIG_ATTRS);
},
actions: {
save(method, data) {
this.set('loading', true);
const hasData = Object.keys(data).some((key) => {
return isPresent(data[key]);
});
if (!hasData) {
return;
}
this.model
.save({
adapterOptions: {
adapterMethod: method,
data,
},
})
.then(() => {
this.reset();
this.flashMessages.success('The backend configuration saved successfully!');
})
.finally(() => {
this.set('loading', false);
});
},
},
});
export default class SecretsBackendConfigurationEditController extends Controller {
get isWifEngine() {
return WIF_ENGINES.includes(this.model.type);
}
get displayName() {
return allEngines().find((engine) => engine.type === this.model.type)?.displayName;
}
}

View File

@@ -74,21 +74,21 @@ export default class AwsRootConfig extends Model {
return formFields.filter((attr) => attr.name !== 'secretKey');
}
// "filedGroupsWif" and "fieldGroupsIam" are passed to the FormFieldGroups component to determine which group to show in the form (ex: @groupName="fieldGroupsWif")
// "filedGroupsWif" and "fieldGroupsAccount" are passed to the FormFieldGroups component to determine which group to show in the form (ex: @groupName="fieldGroupsWif")
get fieldGroupsWif() {
return fieldToAttrs(this, this.formFieldGroups('wif'));
}
get fieldGroupsIam() {
return fieldToAttrs(this, this.formFieldGroups('iam'));
get fieldGroupsAccount() {
return fieldToAttrs(this, this.formFieldGroups('account'));
}
formFieldGroups(accessType = 'iam') {
formFieldGroups(accessType = 'account') {
const formFieldGroups = [];
if (accessType === 'wif') {
formFieldGroups.push({ default: ['roleArn', 'identityTokenAudience', 'identityTokenTtl'] });
}
if (accessType === 'iam') {
if (accessType === 'account') {
formFieldGroups.push({ default: ['accessKey', 'secretKey'] });
}
formFieldGroups.push({

View File

@@ -64,7 +64,7 @@ export default class AzureConfig extends Model {
return !!this.identityTokenAudience || !!this.identityTokenTtl;
}
get isAzureAccountConfigured() {
get isAccountPluginConfigured() {
// clientSecret is not checked here because it's never return by the API
// however it is an Azure account field
return !!this.rootPasswordTtl;
@@ -79,16 +79,16 @@ export default class AzureConfig extends Model {
return formFields.filter((attr) => attr.name !== 'clientSecret');
}
// "filedGroupsWif" and "fieldGroupsAzure" are passed to the FormFieldGroups component to determine which group to show in the form (ex: @groupName="fieldGroupsWif")
// "filedGroupsWif" and "fieldGroupsAccount" are passed to the FormFieldGroups component to determine which group to show in the form (ex: @groupName="fieldGroupsWif")
get fieldGroupsWif() {
return fieldToAttrs(this, this.formFieldGroups('wif'));
}
get fieldGroupsAzure() {
return fieldToAttrs(this, this.formFieldGroups('azure'));
get fieldGroupsAccount() {
return fieldToAttrs(this, this.formFieldGroups('account'));
}
formFieldGroups(accessType = 'azure') {
formFieldGroups(accessType = 'account') {
const formFieldGroups = [];
formFieldGroups.push({
default: ['subscriptionId', 'tenantId', 'clientId', 'environment'],
@@ -98,7 +98,7 @@ export default class AzureConfig extends Model {
default: ['identityTokenAudience', 'identityTokenTtl'],
});
}
if (accessType === 'azure') {
if (accessType === 'account') {
formFieldGroups.push({
default: ['clientSecret', 'rootPasswordTtl'],
});

View File

@@ -19,8 +19,8 @@ import type VersionService from 'vault/services/version';
// It generates config models based on the engine type.
// Saving and updating of those models are done within the engine specific components.
const CONFIG_ADAPTERS_PATHS: Record<string, string[]> = {
aws: ['aws/lease-config', 'aws/root-config'],
const MOUNT_CONFIG_MODEL_NAMES: Record<string, string[]> = {
aws: ['aws/root-config', 'aws/lease-config'],
azure: ['azure/config'],
ssh: ['ssh/ca-config'],
};
@@ -29,6 +29,19 @@ export default class SecretsBackendConfigurationEdit extends Route {
@service declare readonly store: Store;
@service declare readonly version: VersionService;
standardizedModelName(type: string, modelName: string) {
// to determine if there is an additional config model, we check if the modelName is the same as the second element in the array.
const path =
MOUNT_CONFIG_MODEL_NAMES[type] && MOUNT_CONFIG_MODEL_NAMES[type].length > 1
? MOUNT_CONFIG_MODEL_NAMES[type][1]
: null;
if (modelName === path) {
return 'additional-config-model';
} else {
return 'mount-config-model';
}
}
async model() {
const { backend } = this.paramsFor('vault.cluster.secrets.backend');
const secretEngineRecord = this.modelFor('vault.cluster.secrets.backend') as SecretEngineModel;
@@ -43,12 +56,12 @@ export default class SecretsBackendConfigurationEdit extends Route {
// generate the model based on the engine type.
// and pre-set model with type and backend e.g. {type: ssh, id: ssh-123}
const model: Record<string, unknown> = { type, id: backend };
for (const adapterPath of CONFIG_ADAPTERS_PATHS[type] as string[]) {
// convert the adapterPath with a name that can be passed to the components
// ex: adapterPath = ssh/ca-config, convert to: ssh-ca-config so that you can pass to component @model={{this.model.ssh-ca-config}}
const standardizedKey = adapterPath.replace(/\//g, '-');
for (const modelName of MOUNT_CONFIG_MODEL_NAMES[type] as string[]) {
// create a key that corresponds with the model order
// ex: modelName = aws/lease-config, convert to: additional-config-model so that you can pass to component @additionalConfigModel={{this.model.additional-config-model}}
const standardizedKey = this.standardizedModelName(type, modelName);
try {
const configModel = await this.store.queryRecord(adapterPath, {
const configModel = await this.store.queryRecord(modelName, {
backend,
type,
});
@@ -56,7 +69,7 @@ export default class SecretsBackendConfigurationEdit extends Route {
// so instead of checking a catch or httpStatus, we check if the model is configured based on the getter `isConfigured` on the engine's model
// if the engine is not configured we update the record to get the default values
if (!configModel.isConfigured && type === 'azure') {
model[standardizedKey] = await this.store.createRecord(adapterPath, {
model[standardizedKey] = await this.store.createRecord(modelName, {
backend,
type,
});
@@ -71,7 +84,7 @@ export default class SecretsBackendConfigurationEdit extends Route {
e.httpStatus === 404 ||
(type === 'ssh' && e.httpStatus === 400 && errorMessage(e) === `keys haven't been configured yet`)
) {
model[standardizedKey] = await this.store.createRecord(adapterPath, {
model[standardizedKey] = await this.store.createRecord(modelName, {
backend,
type,
});

View File

@@ -15,7 +15,7 @@
<p.levelLeft>
<h1 class="title is-3" data-test-backend-configure-title={{this.model.type}}>
Configure
{{get (options-for-backend this.model.type) "displayName"}}
{{this.displayName}}
</h1>
</p.levelLeft>
</PageHeader>
@@ -28,19 +28,16 @@
</ToolbarActions>
</Toolbar>
{{#if (eq this.model.type "aws")}}
<SecretEngine::ConfigureAws
@leaseConfig={{this.model.aws-lease-config}}
@rootConfig={{this.model.aws-root-config}}
@issuerConfig={{this.model.identity-oidc-config}}
@backendPath={{this.model.id}}
/>
{{else if (eq this.model.type "azure")}}
<SecretEngine::ConfigureAzure
@model={{this.model.azure-config}}
{{#if (eq this.model.type "ssh")}}
<SecretEngine::ConfigureSsh @model={{this.model.mount-config-model}} @id={{this.model.id}} />
{{! This "else if" check is preventive. As of writing, all engines using this route, but "ssh", are wif engines }}
{{else if this.isWifEngine}}
<SecretEngine::ConfigureWif
@backendPath={{this.model.id}}
@displayName={{this.displayName}}
@type={{this.model.type}}
@mountConfigModel={{this.model.mount-config-model}}
@additionalConfigModel={{this.model.additional-config-model}}
@issuerConfig={{this.model.identity-oidc-config}}
/>
{{else if (eq this.model.type "ssh")}}
<SecretEngine::ConfigureSsh @model={{this.model.ssh-ca-config}} @id={{this.model.id}} />
{{/if}}

View File

@@ -34,10 +34,12 @@ module('Acceptance | aws | configuration', function (hooks) {
this.store = this.owner.lookup('service:store');
this.flashSuccessSpy = spy(flash, 'success');
this.flashInfoSpy = spy(flash, 'info');
this.flashDangerSpy = spy(flash, 'danger');
this.version = this.owner.lookup('service:version');
this.uid = uuidv4();
return authPage.login();
});
module('isEnterprise', function (hooks) {
hooks.beforeEach(function () {
this.version.type = 'enterprise';
@@ -62,7 +64,13 @@ module('Acceptance | aws | configuration', function (hooks) {
await click(SES.configure);
assert.strictEqual(currentURL(), `/vault/secrets/${path}/configuration/edit`);
assert.dom(SES.configureTitle('aws')).hasText('Configure AWS');
assert.dom(SES.aws.rootForm).exists('it lands on the root configuration form.');
assert.dom(SES.configureForm).exists('it lands on the configuration form.');
assert
.dom(SES.additionalConfigModelTitle)
.hasText(
'Lease Configuration',
'it shows the lease configuration section with the "Lease Configuration" title.'
);
// cleanup
await runCmd(`delete sys/mounts/${path}`);
});
@@ -83,9 +91,8 @@ module('Acceptance | aws | configuration', function (hooks) {
await enablePage.enable('aws', path);
this.server.post(configUrl('aws-lease', path), () => {
assert.false(
true,
'post request was made to config/lease when no data was changed. test should fail.'
throw new Error(
'A POST request was made to config/lease when it should not because no data was changed.'
);
});
@@ -93,13 +100,13 @@ module('Acceptance | aws | configuration', function (hooks) {
await click(SES.configure);
await fillInAwsConfig('withWif');
await click(GENERAL.saveButton);
assert.dom(SES.wif.issuerWarningModal).exists('issue warning modal exists');
assert.dom(SES.wif.issuerWarningModal).exists('issuer warning modal exists');
await click(SES.wif.issuerWarningSave);
// three flash messages, the first is about mounting the engine, only care about the last two
assert.strictEqual(
this.flashSuccessSpy.args[1][0],
`Successfully saved ${path}'s root configuration.`,
'first flash message about the root config.'
`Successfully saved ${path}'s configuration.`,
'first flash message about the first model config.'
);
assert.strictEqual(
this.flashSuccessSpy.args[2][0],
@@ -124,11 +131,11 @@ module('Acceptance | aws | configuration', function (hooks) {
const type = 'aws';
this.server.get(`${path}/config/root`, (schema, req) => {
const payload = JSON.parse(req.requestBody);
assert.ok(true, 'request made to config/root when navigating to the configuration page.');
assert.true(true, 'request made to config/root when navigating to the configuration page.');
return { data: { id: path, type, attributes: payload } };
});
this.server.get(`identity/oidc/config`, () => {
assert.false(true, 'request made to return issuer. test should fail.');
throw new Error(`Request was made to return the issuer when it should not have been.`);
});
await enablePage.enable(type, path);
createConfig(this.store, path, type); // create the aws root config in the store
@@ -145,10 +152,7 @@ module('Acceptance | aws | configuration', function (hooks) {
await enablePage.enable('aws', path);
this.server.post(configUrl('aws-lease', path), () => {
assert.false(
true,
'post request was made to config/lease when no data was changed. test should fail.'
);
throw new Error(`post request was made to config/lease when it should not have been.`);
});
await click(SES.configTab);
@@ -156,8 +160,8 @@ module('Acceptance | aws | configuration', function (hooks) {
await fillInAwsConfig('withAccess');
await click(GENERAL.saveButton);
assert.true(
this.flashSuccessSpy.calledWith(`Successfully saved ${path}'s root configuration.`),
'Success flash message is rendered showing the root configuration was saved.'
this.flashSuccessSpy.calledWith(`Successfully saved ${path}'s configuration.`),
'Success flash message is rendered showing the configuration was saved.'
);
assert.dom(GENERAL.infoRowValue('Access key')).hasText('foo', 'Access Key has been set.');
assert
@@ -190,40 +194,12 @@ module('Acceptance | aws | configuration', function (hooks) {
await runCmd(`delete sys/mounts/${path}`);
});
test('it should save lease AWS configuration', async function (assert) {
assert.expect(3);
const path = `aws-${this.uid}`;
await enablePage.enable('aws', path);
this.server.post(configUrl('aws', path), () => {
assert.false(
true,
'post request was made to config/root when no data was changed. test should fail.'
);
});
await click(SES.configTab);
await click(SES.configure);
await fillInAwsConfig('withLease');
await click(GENERAL.saveButton);
assert.true(
this.flashSuccessSpy.calledWith(`Successfully saved ${path}'s lease configuration.`),
'Success flash message is rendered showing the lease configuration was saved.'
);
assert
.dom(GENERAL.infoRowValue('Default Lease TTL'))
.hasText('33 seconds', `Default TTL has been set.`);
assert.dom(GENERAL.infoRowValue('Max Lease TTL')).hasText('44 seconds', `Max lease TTL has been set.`);
// cleanup
await runCmd(`delete sys/mounts/${path}`);
});
test('it shows AWS mount configuration details', async function (assert) {
const path = `aws-${this.uid}`;
const type = 'aws';
this.server.get(`${path}/config/root`, (schema, req) => {
const payload = JSON.parse(req.requestBody);
assert.ok(true, 'request made to config/root when navigating to the configuration page.');
assert.true(true, 'request made to config/root when navigating to the configuration page.');
return { data: { id: path, type, attributes: payload } };
});
await enablePage.enable(type, path);
@@ -280,19 +256,6 @@ module('Acceptance | aws | configuration', function (hooks) {
await runCmd(`delete sys/mounts/${path}`);
});
test('it should show API error when AWS configuration read fails', async function (assert) {
assert.expect(1);
const path = `aws-${this.uid}`;
const type = 'aws';
await enablePage.enable(type, path);
// interrupt get and return API error
this.server.get(configUrl(type, path), () => {
return overrideResponse(400, { errors: ['bad request'] });
});
await click(SES.configTab);
assert.dom(SES.error.title).hasText('Error', 'shows the secrets backend error route');
});
test('it should not make a post request if lease or root data was unchanged', async function (assert) {
assert.expect(3);
const path = `aws-${this.uid}`;
@@ -300,16 +263,10 @@ module('Acceptance | aws | configuration', function (hooks) {
await enablePage.enable(type, path);
this.server.post(configUrl(type, path), () => {
assert.false(
true,
'post request was made to config/root when no data was changed. test should fail.'
);
throw new Error(`post request was made to config/root when it should not have been.`);
});
this.server.post(configUrl('aws-lease', path), () => {
assert.false(
true,
'post request was made to config/lease when no data was changed. test should fail.'
);
throw new Error(`post request was made to config/lease when it should not have been.`);
});
await click(SES.configTab);
@@ -350,6 +307,35 @@ module('Acceptance | aws | configuration', function (hooks) {
// cleanup
await runCmd(`delete sys/mounts/${path}`);
});
test('it saves lease configuration if root configuration was not changed', async function (assert) {
assert.expect(2);
const path = `aws-${this.uid}`;
await enablePage.enable('aws', path);
this.server.post(configUrl('aws', path), () => {
throw new Error(
`Request was made to save the config/root when it should not have been because the user did not make any changes to this config.`
);
});
await click(SES.configTab);
await click(SES.configure);
await fillInAwsConfig('withLease');
await click(GENERAL.saveButton);
assert.true(
this.flashSuccessSpy.calledWith(`Successfully saved ${path}'s lease configuration.`),
'Success flash message is rendered showing the lease configuration was saved.'
);
assert.strictEqual(
currentURL(),
`/vault/secrets/${path}/configuration`,
'the form transitioned as expected to the details page'
);
// cleanup
await runCmd(`delete sys/mounts/${path}`);
});
});
module('isCommunity', function (hooks) {
@@ -377,5 +363,97 @@ module('Acceptance | aws | configuration', function (hooks) {
// cleanup
await runCmd(`delete sys/mounts/${path}`);
});
module('Error handling', function () {
test('it does not try to save lease configuration if root configuration errored on save', async function (assert) {
assert.expect(1);
const path = `aws-${this.uid}`;
await enablePage.enable('aws', path);
this.server.post(configUrl('aws', path), () => {
assert.true(true, 'post request was made to save aws root config.');
return overrideResponse(400, { errors: ['bad request!'] });
});
this.server.post(configUrl('aws-lease', path), () => {
throw new Error(
`post request was made to config/lease when the first config was not saved. A request to this endpoint should NOT be be made`
);
});
await click(SES.configTab);
await click(SES.configure);
await fillInAwsConfig('withAccess');
await fillInAwsConfig('withLease');
await click(GENERAL.saveButton);
// cleanup
await runCmd(`delete sys/mounts/${path}`);
});
test('it shows a flash message error and transitions if lease configuration errored on save', async function (assert) {
assert.expect(2);
const path = `aws-${this.uid}`;
await enablePage.enable('aws', path);
this.server.post(configUrl('aws', path), () => {
throw new Error(
`Request was made to save the config/root when it should not have been because the user did not make any changes to this config.`
);
});
this.server.post(configUrl('aws-lease', path), () => {
return overrideResponse(400, { errors: ['bad request!'] });
});
await click(SES.configTab);
await click(SES.configure);
await fillInAwsConfig('withLease');
await click(GENERAL.saveButton);
assert.true(
this.flashDangerSpy.calledWith(`Lease configuration was not saved: bad request!`),
'flash danger message is rendered showing the lease configuration was NOT saved.'
);
assert.strictEqual(
currentURL(),
`/vault/secrets/${path}/configuration`,
'lease configuration failed to save but the component transitioned as expected'
);
// cleanup
await runCmd(`delete sys/mounts/${path}`);
});
test('it prevents transition and shows api error if root config errored on save', async function (assert) {
const path = `aws-${this.uid}`;
await enablePage.enable('aws', path);
this.server.post(configUrl('aws', path), () => {
return overrideResponse(400, { errors: ['welp, that did not work!'] });
});
await click(SES.configTab);
await click(SES.configure);
await fillInAwsConfig('withAccess');
await click(GENERAL.saveButton);
assert.dom(GENERAL.messageError).hasText('Error welp, that did not work!', 'API error shows on form');
assert.strictEqual(
currentURL(),
`/vault/secrets/${path}/configuration/edit`,
'the form did not transition because the save failed.'
);
// cleanup
await runCmd(`delete sys/mounts/${path}`);
});
test('it should show API error when AWS configuration read fails', async function (assert) {
assert.expect(1);
const path = `aws-${this.uid}`;
const type = 'aws';
await enablePage.enable(type, path);
// interrupt get and return API error
this.server.get(configUrl(type, path), () => {
return overrideResponse(400, { errors: ['bad request'] });
});
await click(SES.configTab);
assert.dom(SES.error.title).hasText('Error', 'shows the secrets backend error route');
});
});
});
});

View File

@@ -89,7 +89,7 @@ module('Acceptance | Azure | configuration', function (hooks) {
environment: 'AZUREPUBLICCLOUD',
};
this.server.get(`${path}/config`, () => {
assert.ok(true, 'request made to config when navigating to the configuration page.');
assert.true(true, 'request made to config when navigating to the configuration page.');
return { data: { id: path, type: this.type, ...azureAccountAttrs } };
});
await enablePage.enable(this.type, path);
@@ -109,17 +109,6 @@ module('Acceptance | Azure | configuration', function (hooks) {
// cleanup
await runCmd(`delete sys/mounts/${path}`);
});
test('it should show API error when configuration read fails', async function (assert) {
assert.expect(1);
const path = `azure-${this.uid}`;
// interrupt get and return API error
this.server.get(configUrl(this.type, path), () => {
return overrideResponse(400, { errors: ['bad request'] });
});
await enablePage.enable(this.type, path);
assert.dom(SES.error.title).hasText('Error', 'shows the secrets backend error route');
});
});
module('create', function () {
@@ -129,9 +118,8 @@ module('Acceptance | Azure | configuration', function (hooks) {
await enablePage.enable(this.type, path);
this.server.post('/identity/oidc/config', () => {
assert.notOk(
true,
'post request was made to issuer endpoint when on community and data not changed. test should fail.'
throw new Error(
`Request was made to return the issuer when it should not have been because user is on CE.`
);
});
@@ -226,6 +214,42 @@ module('Acceptance | Azure | configuration', function (hooks) {
await runCmd(`delete sys/mounts/${path}`);
});
});
module('Error handling', function () {
test('it prevents transition and shows api error if config errored on save', async function (assert) {
const path = `azure-${this.uid}`;
await enablePage.enable('azure', path);
this.server.post(configUrl('azure', path), () => {
return overrideResponse(400, { errors: ['welp, that did not work!'] });
});
await click(SES.configTab);
await click(SES.configure);
await fillInAzureConfig('azure');
await click(GENERAL.saveButton);
assert.dom(GENERAL.messageError).hasText('Error welp, that did not work!', 'API error shows on form');
assert.strictEqual(
currentURL(),
`/vault/secrets/${path}/configuration/edit`,
'the form did not transition because the save failed.'
);
// cleanup
await runCmd(`delete sys/mounts/${path}`);
});
test('it should show API error when configuration read fails', async function (assert) {
assert.expect(1);
const path = `azure-${this.uid}`;
// interrupt get and return API error
this.server.get(configUrl(this.type, path), () => {
return overrideResponse(400, { errors: ['bad request'] });
});
await enablePage.enable(this.type, path);
assert.dom(SES.error.title).hasText('Error', 'shows the secrets backend error route');
});
});
});
module('isEnterprise', function (hooks) {
@@ -245,7 +269,7 @@ module('Acceptance | Azure | configuration', function (hooks) {
environment: 'AZUREPUBLICCLOUD',
};
this.server.get(`${path}/config`, () => {
assert.ok(true, 'request made to config when navigating to the configuration page.');
assert.true(true, 'request made to config when navigating to the configuration page.');
return { data: { id: path, type: this.type, ...wifAttrs } };
});
await enablePage.enable(this.type, path);
@@ -268,11 +292,11 @@ module('Acceptance | Azure | configuration', function (hooks) {
const path = `azure-${this.uid}`;
this.server.get(`${path}/config`, (schema, req) => {
const payload = JSON.parse(req.requestBody);
assert.ok(true, 'request made to config/root when navigating to the configuration page.');
assert.true(true, 'request made to config/root when navigating to the configuration page.');
return { data: { id: path, type: this.type, attributes: payload } };
});
this.server.get(`identity/oidc/config`, () => {
assert.notOk(true, 'request made to return issuer. test should fail.');
throw new Error(`Request was made to return the issuer when it should not have been.`);
});
await createConfig(this.store, path, this.type); // create the azure account config in the store
await enablePage.enable(this.type, path);

View File

@@ -72,7 +72,7 @@ module('Acceptance | GCP | configuration', function (hooks) {
ttl: 3600,
};
this.server.get(`${path}/config`, () => {
assert.ok(true, 'request made to config when navigating to the configuration page.');
assert.true(true, 'request made to config when navigating to the configuration page.');
return { data: { id: path, type: this.type, ...wifAttrs } };
});
await enablePage.enable(this.type, path);
@@ -99,7 +99,7 @@ module('Acceptance | GCP | configuration', function (hooks) {
max_ttl: '4 hours',
};
this.server.get(`${path}/config`, () => {
assert.ok(true, 'request made to config when navigating to the configuration page.');
assert.true(true, 'request made to config when navigating to the configuration page.');
return { data: { id: path, type: this.type, ...GCPAccountAttrs } };
});
await enablePage.enable(this.type, path);

View File

@@ -6,6 +6,7 @@
export const SECRET_ENGINE_SELECTORS = {
configTab: '[data-test-configuration-tab]',
configure: '[data-test-secret-backend-configure]',
configureNote: (name: string) => `[data-test-configure-note="${name}"]`,
configureTitle: (type: string) => `[data-test-backend-configure-title="${type}"]`,
configurationToggle: '[data-test-mount-config-toggle]',
createSecret: '[data-test-secret-create]',
@@ -24,6 +25,7 @@ export const SECRET_ENGINE_SELECTORS = {
viewBackend: '[data-test-backend-view-link]',
warning: '[data-test-warning]',
configureForm: '[data-test-configure-form]',
additionalConfigModelTitle: '[data-test-additional-config-model-title]',
wif: {
accessTypeSection: '[data-test-access-type-section]',
accessTitle: '[data-test-access-title]',
@@ -35,8 +37,6 @@ export const SECRET_ENGINE_SELECTORS = {
issuerWarningSave: '[data-test-issuer-save]',
},
aws: {
rootForm: '[data-test-root-form]',
leaseTitle: '[data-test-lease-title]',
deleteRole: (role: string) => `[data-test-aws-role-delete="${role}"]`,
},
ssh: {

View File

@@ -1,578 +0,0 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import { module, test } from 'qunit';
import sinon from 'sinon';
import { setupRenderingTest } from 'vault/tests/helpers';
import { GENERAL } from 'vault/tests/helpers/general-selectors';
import { SECRET_ENGINE_SELECTORS as SES } from 'vault/tests/helpers/secret-engine/secret-engine-selectors';
import { render, click, fillIn } from '@ember/test-helpers';
import { setupMirage } from 'ember-cli-mirage/test-support';
import { hbs } from 'ember-cli-htmlbars';
import { v4 as uuidv4 } from 'uuid';
import { overrideResponse } from 'vault/tests/helpers/stubs';
import {
expectedConfigKeys,
createConfig,
configUrl,
fillInAwsConfig,
} from 'vault/tests/helpers/secret-engine/secret-engine-helpers';
import { capabilitiesStub } from 'vault/tests/helpers/stubs';
module('Integration | Component | SecretEngine/ConfigureAws', function (hooks) {
setupRenderingTest(hooks);
setupMirage(hooks);
hooks.beforeEach(function () {
this.store = this.owner.lookup('service:store');
this.flashMessages = this.owner.lookup('service:flash-messages');
this.flashMessages.registerTypes(['success', 'danger']);
this.flashSuccessSpy = sinon.spy(this.flashMessages, 'success');
this.flashDangerSpy = sinon.spy(this.flashMessages, 'danger');
this.transitionStub = sinon.stub(this.owner.lookup('service:router'), 'transitionTo');
this.uid = uuidv4();
this.id = `aws-${this.uid}`;
// using createRecord on root and lease configs to simulate a fresh mount
this.rootConfig = this.store.createRecord('aws/root-config');
this.leaseConfig = this.store.createRecord('aws/lease-config');
// issuer config is never a createdRecord but the response from the API.
this.issuerConfig = createConfig(this.store, this.id, 'issuer');
// Add backend to the configs because it's not on the testing snapshot (would come from url)
this.rootConfig.backend = this.leaseConfig.backend = this.id;
this.version = this.owner.lookup('service:version');
// stub capabilities so that by default user can read and update issuer
this.server.post('/sys/capabilities-self', () => capabilitiesStub('identity/oidc/config', ['sudo']));
this.renderComponent = () => {
return render(hbs`
<SecretEngine::ConfigureAws @rootConfig={{this.rootConfig}} @leaseConfig={{this.leaseConfig}} @issuerConfig={{this.issuerConfig}} @backendPath={{this.id}} />
`);
};
});
module('Create view', function () {
module('isEnterprise', function (hooks) {
hooks.beforeEach(function () {
this.version.type = 'enterprise';
});
test('it renders fields ', async function (assert) {
await this.renderComponent();
assert.dom(SES.aws.rootForm).exists('it lands on the aws root configuration form.');
assert.dom(SES.wif.accessTitle).exists('Access section is rendered');
assert.dom(SES.aws.leaseTitle).exists('Lease section is rendered');
assert.dom(SES.wif.accessTypeSection).exists('Access type section is rendered');
assert.dom(SES.wif.accessType('iam')).isChecked('defaults to showing IAM access type checked');
assert.dom(SES.wif.accessType('wif')).isNotChecked('wif access type is not checked');
// check all the form fields are present
await click(GENERAL.toggleGroup('Root config options'));
for (const key of expectedConfigKeys('aws', true)) {
assert.dom(GENERAL.inputByAttr(key)).exists(`${key} shows for root section.`);
}
for (const key of expectedConfigKeys('aws-lease')) {
assert.dom(`[data-test-ttl-form-label="${key}"]`).exists(`${key} shows for Lease section.`);
}
assert.dom(GENERAL.inputByAttr('issuer')).doesNotExist();
});
test('it renders wif fields when selected', async function (assert) {
await this.renderComponent();
await click(SES.wif.accessType('wif'));
// check for the wif fields only
for (const key of expectedConfigKeys('aws-wif', true)) {
if (key === 'Identity token TTL') {
assert.dom(GENERAL.ttl.toggle(key)).exists(`${key} shows for wif section.`);
} else {
assert.dom(GENERAL.inputByAttr(key)).exists(`${key} shows for wif section.`);
}
}
// check iam fields do not show
for (const key of expectedConfigKeys('aws', true)) {
assert.dom(GENERAL.inputByAttr(key)).doesNotExist(`${key} does not show when wif is selected.`);
}
});
test('it clears wif/iam inputs after toggling accessType', async function (assert) {
await this.renderComponent();
await fillInAwsConfig('withAccess');
await fillInAwsConfig('withLease');
await click(SES.wif.accessType('wif')); // toggle to wif
await fillInAwsConfig('withWif');
await click(SES.wif.accessType('iam')); // toggle to wif
assert
.dom(GENERAL.inputByAttr('accessKey'))
.hasValue('', 'accessKey is cleared after toggling accessType');
assert
.dom(GENERAL.inputByAttr('secretKey'))
.hasValue('', 'secretKey is cleared after toggling accessType');
await click(SES.wif.accessType('wif'));
assert
.dom(GENERAL.inputByAttr('issuer'))
.hasValue('', 'issue shows no value after toggling accessType');
assert
.dom(GENERAL.inputByAttr('issuer'))
.hasAttribute(
'placeholder',
'https://vault-test.com',
'issue shows no value after toggling accessType'
);
assert
.dom(GENERAL.inputByAttr('roleArn'))
.hasValue('', 'roleArn is cleared after toggling accessType');
assert
.dom(GENERAL.inputByAttr('identityTokenAudience'))
.hasValue('', 'identityTokenAudience is cleared after toggling accessType');
assert
.dom(GENERAL.toggleInput('Identity token TTL'))
.isNotChecked('identityTokenTtl is cleared after toggling accessType');
});
test('it does not clear global issuer when toggling accessType', async function (assert) {
this.issuerConfig = createConfig(this.store, this.id, 'issuer');
await this.renderComponent();
await click(SES.wif.accessType('wif'));
assert
.dom(GENERAL.inputByAttr('issuer'))
.hasValue(this.issuerConfig.issuer, 'issuer is what is sent in my the model on first load');
await fillIn(GENERAL.inputByAttr('issuer'), 'http://ive-changed');
await click(SES.wif.accessType('iam'));
await click(SES.wif.accessType('wif'));
assert
.dom(GENERAL.inputByAttr('issuer'))
.hasValue(
this.issuerConfig.issuer,
'issuer value is still the same global value after toggling accessType'
);
});
test('it shows validation error if default lease is entered but max lease is not', async function (assert) {
assert.expect(2);
await this.renderComponent();
this.server.post(configUrl('aws-lease', this.id), () => {
assert.false(
true,
'post request was made to config/lease when no data was changed. test should fail.'
);
});
this.server.post(configUrl('aws', this.id), () => {
assert.false(
true,
'post request was made to config/root when no data was changed. test should fail.'
);
});
await click(GENERAL.ttl.toggle('Default Lease TTL'));
await fillIn(GENERAL.ttl.input('Default Lease TTL'), '33');
await click(GENERAL.saveButton);
assert
.dom(GENERAL.inlineError)
.hasText('Lease TTL and Max Lease TTL are both required if one of them is set.');
assert.dom(SES.aws.rootForm).exists('remains on the configuration form');
});
test('it surfaces the API error if one occurs on root/config, preventing user from transitioning', async function (assert) {
assert.expect(3);
await this.renderComponent();
this.server.post(configUrl('aws', this.id), () => {
return overrideResponse(400, { errors: ['bad request'] });
});
this.server.post(configUrl('aws-lease', this.id), () => {
assert.true(
true,
'post request was made to config/lease when config/root failed. test should pass.'
);
});
// fill in both lease and root endpoints to ensure that both payloads are attempted to be sent
await fillInAwsConfig('withAccess');
await fillInAwsConfig('withLease');
await click(GENERAL.saveButton);
assert.dom(GENERAL.messageError).exists('API error surfaced to user');
assert.dom(GENERAL.inlineError).exists('User shown inline error message');
});
test('it allows user to submit root config even if API error occurs on config/lease config', async function (assert) {
assert.expect(3);
await this.renderComponent();
this.server.post(configUrl('aws', this.id), () => {
assert.true(
true,
'post request was made to config/root when config/lease failed. test should pass.'
);
});
this.server.post(configUrl('aws-lease', this.id), () => {
return overrideResponse(400, { errors: ['bad request'] });
});
// fill in both lease and root endpoints to ensure that both payloads are attempted to be sent
await fillInAwsConfig('withAccess');
await fillInAwsConfig('withLease');
await click(GENERAL.saveButton);
assert.true(
this.flashDangerSpy.calledWith('Lease configuration was not saved: bad request'),
'Flash message shows that lease was not saved.'
);
assert.ok(
this.transitionStub.calledWith('vault.cluster.secrets.backend.configuration', this.id),
'Transitioned to the configuration index route.'
);
});
test('it allows user to submit root config even if API error occurs on issuer config', async function (assert) {
assert.expect(4);
await this.renderComponent();
this.server.post(configUrl('aws', this.id), () => {
assert.true(true, 'post request was made to config/root when issuer failed. test should pass.');
});
this.server.post('/identity/oidc/config', () => {
return overrideResponse(400, { errors: ['bad request'] });
});
await fillInAwsConfig('withWif');
await click(GENERAL.saveButton);
await click(SES.wif.issuerWarningSave);
assert.true(
this.flashDangerSpy.calledWith('Issuer was not saved: bad request'),
'Flash message shows that issuer was not saved'
);
assert.true(
this.flashSuccessSpy.calledWith(`Successfully saved ${this.id}'s root configuration.`),
'Flash message shows that root was saved even if issuer was not'
);
assert.ok(
this.transitionStub.calledWith('vault.cluster.secrets.backend.configuration', this.id),
'Transitioned to the configuration index route.'
);
});
test('it transitions without sending a lease, root, or issuer payload on cancel', async function (assert) {
assert.expect(3);
await this.renderComponent();
this.server.post(configUrl('aws', this.id), () => {
assert.true(
false,
'post request was made to config/root when user canceled out of flow. test should fail.'
);
});
this.server.post(configUrl('aws-lease', this.id), () => {
assert.true(
false,
'post request was made to config/lease when user canceled out of flow. test should fail.'
);
});
this.server.post('/identity/oidc/config', () => {
assert.true(
false,
'post request was made to save issuer when user canceled out of flow. test should fail.'
);
});
// fill in both lease and root endpoints to ensure that both payloads are attempted to be sent
await fillInAwsConfig('withWif');
await fillInAwsConfig('withLease');
await click(GENERAL.cancelButton);
assert.true(this.flashDangerSpy.notCalled, 'No danger flash messages called.');
assert.true(this.flashSuccessSpy.notCalled, 'No success flash messages called.');
assert.ok(
this.transitionStub.calledWith('vault.cluster.secrets.backend.configuration', this.id),
'Transitioned to the configuration index route.'
);
});
module('issuer field tests', function () {
// the other tests where issuer is not passed do not show modals, so we only need to test when the modal should shows up
test('if issuer API error and user changes issuer value, shows specific warning message', async function (assert) {
this.issuerConfig.queryIssuerError = true;
await this.renderComponent();
await click(SES.wif.accessType('wif'));
await fillIn(GENERAL.inputByAttr('issuer'), 'http://change.me.no.read');
await click(GENERAL.saveButton);
assert
.dom(SES.wif.issuerWarningMessage)
.hasText(
`You are updating the global issuer config. This will overwrite Vault's current issuer if it exists and may affect other configurations using this value. Continue?`,
'modal shows message about overwriting value if it exists'
);
});
test('is shows placeholder issuer, shows modal when saving changes, and does not call APIs on cancel', async function (assert) {
this.server.post('/identity/oidc/config', () => {
assert.notOk(true, 'request should not be made to issuer config endpoint');
});
this.server.post(configUrl('aws', this.id), () => {
assert.notOk(
true,
'post request was made to config/root when user canceled out of flow. test should fail.'
);
});
this.server.post(configUrl('aws-lease', this.id), () => {
assert.notOk(
true,
'post request was made to config/lease when user canceled out of flow. test should fail.'
);
});
await this.renderComponent();
await click(SES.wif.accessType('wif'));
assert
.dom(GENERAL.inputByAttr('issuer'))
.hasAttribute('placeholder', 'https://vault-test.com', 'shows issuer placeholder');
assert.dom(GENERAL.inputByAttr('issuer')).hasValue('', 'shows issuer is empty when not passed');
await fillIn(GENERAL.inputByAttr('issuer'), 'http://bar.foo');
await click(GENERAL.saveButton);
assert.dom(SES.wif.issuerWarningModal).exists('issuer modal exists');
assert
.dom(SES.wif.issuerWarningMessage)
.hasText(
`You are updating the global issuer config. This will overwrite Vault's current issuer and may affect other configurations using this value. Continue?`,
'modal shows message about overwriting value without the noRead: "if it exists" adage'
);
await click(SES.wif.issuerWarningCancel);
assert.dom(SES.wif.issuerWarningModal).doesNotExist('issuer modal is removed on cancel');
assert.true(this.flashDangerSpy.notCalled, 'No danger flash messages called.');
assert.true(this.flashSuccessSpy.notCalled, 'No success flash messages called.');
assert.true(this.transitionStub.notCalled, 'Does not redirect');
});
test('it shows modal when updating issuer and calls correct APIs on save', async function (assert) {
const newIssuer = 'http://bar.foo';
this.server.post('/identity/oidc/config', (schema, req) => {
const payload = JSON.parse(req.requestBody);
assert.deepEqual(payload, { issuer: newIssuer }, 'payload for issuer is correct');
return {
id: 'identity-oidc-config', // id needs to match the id on secret-engine-helpers createIssuerConfig
data: null,
warnings: [
'If "issuer" is set explicitly, all tokens must be validated against that address, including those issued by secondary clusters. Setting issuer to "" will restore the default behavior of using the cluster\'s api_addr as the issuer.',
],
};
});
this.server.post(configUrl('aws', this.id), () => {
assert.notOk(true, 'skips request to config/root due to no changes');
});
this.server.post(configUrl('aws-lease', this.id), () => {
assert.notOk(true, 'skips request to config/lease due to no changes');
});
await this.renderComponent();
await click(SES.wif.accessType('wif'));
assert.dom(GENERAL.inputByAttr('issuer')).hasValue('', 'issuer defaults to empty string');
await fillIn(GENERAL.inputByAttr('issuer'), newIssuer);
await click(GENERAL.saveButton);
assert.dom(SES.wif.issuerWarningModal).exists('issue warning modal exists');
await click(SES.wif.issuerWarningSave);
assert.true(this.flashDangerSpy.notCalled, 'No danger flash messages called.');
assert.true(
this.flashSuccessSpy.calledWith('Issuer saved successfully'),
'Success flash message called for issuer'
);
assert.ok(
this.transitionStub.calledWith('vault.cluster.secrets.backend.configuration', this.id),
'Transitioned to the configuration index route.'
);
});
test('shows modal when modifying the issuer, has correct payload, and shows flash message on fail', async function (assert) {
assert.expect(7);
this.issuer = 'http://foo.bar';
this.server.post(configUrl('aws', this.id), () => {
assert.true(
true,
'post request was made to config/root when unsetting the issuer. test should pass.'
);
});
this.server.post('/identity/oidc/config', (_, req) => {
const payload = JSON.parse(req.requestBody);
assert.deepEqual(payload, { issuer: this.issuer }, 'correctly sets the issuer');
return overrideResponse(403);
});
await this.renderComponent();
await click(SES.wif.accessType('wif'));
assert.dom(GENERAL.inputByAttr('issuer')).hasValue('');
await fillIn(GENERAL.inputByAttr('issuer'), this.issuer);
await fillIn(GENERAL.inputByAttr('roleArn'), 'some-other-value');
await click(GENERAL.saveButton);
assert.dom(SES.wif.issuerWarningModal).exists('issuer warning modal exists');
await click(SES.wif.issuerWarningSave);
assert.true(
this.flashDangerSpy.calledWith('Issuer was not saved: permission denied'),
'shows danger flash for issuer save'
);
assert.true(
this.flashSuccessSpy.calledWith(`Successfully saved ${this.id}'s root configuration.`),
"calls the root flash message not the issuer's"
);
assert.ok(
this.transitionStub.calledWith('vault.cluster.secrets.backend.configuration', this.id),
'Transitioned to the configuration index route.'
);
});
});
});
module('isCommunity', function (hooks) {
hooks.beforeEach(function () {
this.version.type = 'community';
});
test('it renders fields', async function (assert) {
assert.expect(13);
await this.renderComponent();
assert.dom(SES.aws.rootForm).exists('it lands on the aws root configuration form.');
assert.dom(SES.wif.accessTitle).exists('Access section is rendered');
assert.dom(SES.aws.leaseTitle).exists('Lease section is rendered');
assert
.dom(SES.wif.accessTypeSection)
.doesNotExist('Access type section does not render for a community user');
// check all the form fields are present
await click(GENERAL.toggleGroup('Root config options'));
for (const key of expectedConfigKeys('aws', true)) {
assert.dom(GENERAL.inputByAttr(key)).exists(`${key} shows for root section.`);
}
for (const key of expectedConfigKeys('aws-lease')) {
assert.dom(`[data-test-ttl-form-label="${key}"]`).exists(`${key} shows for Lease section.`);
}
assert.dom(GENERAL.inputByAttr('issuer')).doesNotExist();
});
test('it does not send issuer on save', async function (assert) {
assert.expect(4);
await this.renderComponent();
this.server.post(configUrl('aws', this.id), () => {
assert.true(true, 'post request was made to config/root. test should pass.');
});
this.server.post('/identity/oidc/config', () => {
throw new Error('post request was incorrectly made to update issuer');
});
await fillInAwsConfig('withAccess');
await fillInAwsConfig('withLease');
await click(GENERAL.saveButton);
assert.dom(SES.wif.issuerWarningModal).doesNotExist('modal should not render');
assert.true(
this.flashSuccessSpy.calledWith(`Successfully saved ${this.id}'s root configuration.`),
'Flash message shows that root was saved even if issuer was not'
);
assert.ok(
this.transitionStub.calledWith('vault.cluster.secrets.backend.configuration', this.id),
'Transitioned to the configuration index route.'
);
});
});
});
module('Edit view', function (hooks) {
hooks.beforeEach(function () {
this.rootConfig = createConfig(this.store, this.id, 'aws');
this.leaseConfig = createConfig(this.store, this.id, 'aws-lease');
});
module('isEnterprise', function (hooks) {
hooks.beforeEach(function () {
this.version.type = 'enterprise';
});
test('it defaults to IAM accessType if IAM fields are already set', async function (assert) {
await this.renderComponent();
assert.dom(SES.wif.accessType('iam')).isChecked('IAM accessType is checked');
assert.dom(SES.wif.accessType('iam')).isDisabled('IAM accessType is disabled');
assert.dom(SES.wif.accessType('wif')).isNotChecked('WIF accessType is not checked');
assert.dom(SES.wif.accessType('wif')).isDisabled('WIF accessType is disabled');
assert
.dom(SES.wif.accessTypeSubtext)
.hasText('You cannot edit Access Type if you have already saved access credentials.');
});
test('it defaults to WIF accessType if WIF fields are already set', async function (assert) {
this.rootConfig = createConfig(this.store, this.id, 'aws-wif');
await this.renderComponent();
assert.dom(SES.wif.accessType('wif')).isChecked('WIF accessType is checked');
assert.dom(SES.wif.accessType('wif')).isDisabled('WIF accessType is disabled');
assert.dom(SES.wif.accessType('iam')).isNotChecked('IAM accessType is not checked');
assert.dom(SES.wif.accessType('iam')).isDisabled('IAM accessType is disabled');
assert.dom(GENERAL.inputByAttr('roleArn')).hasValue(this.rootConfig.roleArn);
assert
.dom(SES.wif.accessTypeSubtext)
.hasText('You cannot edit Access Type if you have already saved access credentials.');
assert
.dom(GENERAL.inputByAttr('identityTokenAudience'))
.hasValue(this.rootConfig.identityTokenAudience);
assert.dom(GENERAL.ttl.input('Identity token TTL')).hasValue('2'); // 7200 on payload is 2hrs in ttl picker
});
test('it renders issuer if global issuer is already set', async function (assert) {
this.rootConfig = createConfig(this.store, this.id, 'aws-wif');
this.issuerConfig = createConfig(this.store, this.id, 'issuer');
await this.renderComponent();
assert.dom(SES.wif.accessType('wif')).isChecked('WIF accessType is checked');
assert.dom(SES.wif.accessType('wif')).isDisabled('WIF accessType is disabled');
assert
.dom(GENERAL.inputByAttr('issuer'))
.hasValue(this.issuerConfig.issuer, 'it has the models issuer value');
});
test('it allows you to change access type if record does not have wif or iam values already set', async function (assert) {
// the model does not have to be new for a user to see the option to change the access type.
// the access type is only disabled if the model has values already set for access type fields.
this.rootConfig = createConfig(this.store, this.id, 'aws-no-access');
await this.renderComponent();
assert.dom(SES.wif.accessType('wif')).isNotDisabled('WIF accessType is NOT disabled');
assert.dom(SES.wif.accessType('iam')).isNotDisabled('IAM accessType is NOT disabled');
});
test('it shows previously saved root and lease information', async function (assert) {
await this.renderComponent();
assert.dom(GENERAL.inputByAttr('accessKey')).hasValue(this.rootConfig.accessKey);
assert
.dom(GENERAL.inputByAttr('secretKey'))
.hasValue('**********', 'secretKey is masked on edit the value');
await click(GENERAL.toggleGroup('Root config options'));
assert.dom(GENERAL.inputByAttr('region')).hasValue(this.rootConfig.region);
assert.dom(GENERAL.inputByAttr('iamEndpoint')).hasValue(this.rootConfig.iamEndpoint);
assert.dom(GENERAL.inputByAttr('stsEndpoint')).hasValue(this.rootConfig.stsEndpoint);
assert.dom(GENERAL.inputByAttr('maxRetries')).hasValue('1');
// Check lease config values
assert.dom(GENERAL.ttl.input('Default Lease TTL')).hasValue('50');
assert.dom(GENERAL.ttl.input('Max Lease TTL')).hasValue('55');
});
test('it requires a double click to change the secret key', async function (assert) {
await this.renderComponent();
this.server.post(configUrl('aws', this.id), (schema, req) => {
const payload = JSON.parse(req.requestBody);
assert.strictEqual(
payload.secret_key,
'new-secret',
'post request was made to config/root with the updated secret_key.'
);
});
await click(GENERAL.enableField('secretKey'));
await click('[data-test-button="toggle-masked"]');
await fillIn(GENERAL.inputByAttr('secretKey'), 'new-secret');
await click(GENERAL.saveButton);
});
});
module('isCommunity', function (hooks) {
hooks.beforeEach(function () {
this.version.type = 'community';
});
test('it does not show access types but defaults to iam fields', async function (assert) {
await this.renderComponent();
assert.dom(SES.wif.accessTypeSection).doesNotExist('Access type section does not render');
assert.dom(GENERAL.inputByAttr('accessKey')).hasValue(this.rootConfig.accessKey);
assert
.dom(GENERAL.inputByAttr('secretKey'))
.hasValue('**********', 'secretKey is masked on edit the value');
await click(GENERAL.toggleGroup('Root config options'));
assert.dom(GENERAL.inputByAttr('region')).hasValue(this.rootConfig.region);
assert.dom(GENERAL.inputByAttr('iamEndpoint')).hasValue(this.rootConfig.iamEndpoint);
assert.dom(GENERAL.inputByAttr('stsEndpoint')).hasValue(this.rootConfig.stsEndpoint);
assert.dom(GENERAL.inputByAttr('maxRetries')).hasValue('1');
// Check lease config values
assert.dom(GENERAL.ttl.input('Default Lease TTL')).hasValue('50');
assert.dom(GENERAL.ttl.input('Max Lease TTL')).hasValue('55');
});
});
});
});

View File

@@ -1,426 +0,0 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import { module, test } from 'qunit';
import sinon from 'sinon';
import { setupRenderingTest } from 'vault/tests/helpers';
import { GENERAL } from 'vault/tests/helpers/general-selectors';
import { SECRET_ENGINE_SELECTORS as SES } from 'vault/tests/helpers/secret-engine/secret-engine-selectors';
import { render, click, fillIn } from '@ember/test-helpers';
import { setupMirage } from 'ember-cli-mirage/test-support';
import { hbs } from 'ember-cli-htmlbars';
import { v4 as uuidv4 } from 'uuid';
import { overrideResponse } from 'vault/tests/helpers/stubs';
import {
expectedConfigKeys,
createConfig,
configUrl,
fillInAzureConfig,
} from 'vault/tests/helpers/secret-engine/secret-engine-helpers';
import { capabilitiesStub } from 'vault/tests/helpers/stubs';
module('Integration | Component | SecretEngine/ConfigureAzure', function (hooks) {
setupRenderingTest(hooks);
setupMirage(hooks);
hooks.beforeEach(function () {
this.store = this.owner.lookup('service:store');
this.version = this.owner.lookup('service:version');
this.flashMessages = this.owner.lookup('service:flash-messages');
this.flashMessages.registerTypes(['success', 'danger']);
this.flashSuccessSpy = sinon.spy(this.flashMessages, 'success');
this.flashDangerSpy = sinon.spy(this.flashMessages, 'danger');
this.transitionStub = sinon.stub(this.owner.lookup('service:router'), 'transitionTo');
this.uid = uuidv4();
this.id = `azure-${this.uid}`;
this.config = this.store.createRecord('azure/config');
this.issuerConfig = createConfig(this.store, this.id, 'issuer');
this.config.backend = this.id; // Add backend to the configs because it's not on the testing snapshot (would come from url)
// stub capabilities so that by default user can read and update issuer
this.server.post('/sys/capabilities-self', () => capabilitiesStub('identity/oidc/config', ['sudo']));
this.renderComponent = () => {
return render(hbs`
<SecretEngine::ConfigureAzure @model={{this.config}} @issuerConfig={{this.issuerConfig}} @backendPath={{this.id}} />
`);
};
});
module('Create view', function () {
module('isEnterprise', function (hooks) {
hooks.beforeEach(function () {
this.version.type = 'enterprise';
});
test('it renders default fields, showing access type options for enterprise users', async function (assert) {
await this.renderComponent();
assert.dom(SES.configureForm).exists('it lands on the Azure configuration form.');
assert.dom(SES.wif.accessType('azure')).isChecked('defaults to showing Azure access type checked');
assert.dom(SES.wif.accessType('wif')).isNotChecked('wif access type is not checked');
// check all the form fields are present
for (const key of expectedConfigKeys('azure', true)) {
assert.dom(GENERAL.inputByAttr(key)).exists(`${key} shows for root section`);
}
assert.dom(GENERAL.inputByAttr('issuer')).doesNotExist();
});
test('it renders wif fields when user selects wif access type', async function (assert) {
await this.renderComponent();
await click(SES.wif.accessType('wif'));
// check for the wif fields only
for (const key of expectedConfigKeys('azure-wif', true)) {
if (key === 'Identity token TTL') {
assert.dom(GENERAL.ttl.toggle(key)).exists(`${key} shows for wif section.`);
} else {
assert.dom(GENERAL.inputByAttr(key)).exists(`${key} shows for wif section.`);
}
}
assert.dom(GENERAL.inputByAttr('issuer')).exists('issuer shows for wif section.');
});
test('it clears wif/azure-account inputs after toggling accessType', async function (assert) {
await this.renderComponent();
await fillInAzureConfig('azure');
await click(SES.wif.accessType('wif'));
await fillInAzureConfig('withWif');
await click(SES.wif.accessType('azure'));
assert
.dom(GENERAL.toggleInput('Root password TTL'))
.isNotChecked('rootPasswordTtl is cleared after toggling accessType');
assert
.dom(GENERAL.inputByAttr('clientSecret'))
.hasValue('', 'clientSecret is cleared after toggling accessType');
await click(SES.wif.accessType('wif'));
assert
.dom(GENERAL.inputByAttr('issuer'))
.hasValue('', 'issuer shows no value after toggling accessType');
assert
.dom(GENERAL.inputByAttr('issuer'))
.hasAttribute(
'placeholder',
'https://vault-test.com',
'issuer shows no value after toggling accessType'
);
assert
.dom(GENERAL.inputByAttr('identityTokenAudience'))
.hasValue('', 'idTokenAudience is cleared after toggling accessType');
assert
.dom(GENERAL.toggleInput('Identity token TTL'))
.isNotChecked('identityTokenTtl is cleared after toggling accessType');
});
test('it does not clear global issuer when toggling accessType', async function (assert) {
this.issuerConfig = createConfig(this.store, this.id, 'issuer');
await this.renderComponent();
await click(SES.wif.accessType('wif'));
assert
.dom(GENERAL.inputByAttr('issuer'))
.hasValue(this.issuerConfig.issuer, 'issuer is what is sent in by the model on first load');
await fillIn(GENERAL.inputByAttr('issuer'), 'http://ive-changed');
await click(SES.wif.accessType('azure'));
await click(SES.wif.accessType('wif'));
assert
.dom(GENERAL.inputByAttr('issuer'))
.hasValue(
this.issuerConfig.issuer,
'issuer value is still the same global value after toggling accessType'
);
});
test('it transitions without sending a config or issuer payload on cancel', async function (assert) {
assert.expect(3);
await this.renderComponent();
this.server.post(configUrl('azure', this.id), () => {
assert.notOk(
true,
'post request was made to config when user canceled out of flow. test should fail.'
);
});
this.server.post('/identity/oidc/config', () => {
assert.notOk(
true,
'post request was made to save issuer when user canceled out of flow. test should fail.'
);
});
await fillInAzureConfig('withWif');
await click(GENERAL.cancelButton);
assert.true(this.flashDangerSpy.notCalled, 'No danger flash messages called.');
assert.true(this.flashSuccessSpy.notCalled, 'No success flash messages called.');
assert.ok(
this.transitionStub.calledWith('vault.cluster.secrets.backend.configuration', this.id),
'Transitioned to the configuration index route.'
);
});
module('issuer field tests', function () {
test('if issuer API error and user changes issuer value, shows specific warning message', async function (assert) {
this.issuerConfig.queryIssuerError = true;
await this.renderComponent();
await click(SES.wif.accessType('wif'));
await fillIn(GENERAL.inputByAttr('issuer'), 'http://change.me.no.read');
await click(GENERAL.saveButton);
assert
.dom(SES.wif.issuerWarningMessage)
.hasText(
`You are updating the global issuer config. This will overwrite Vault's current issuer if it exists and may affect other configurations using this value. Continue?`,
'modal shows message about overwriting value if it exists'
);
});
test('is shows placeholder issuer, and does not call APIs on canceling out of issuer modal', async function (assert) {
this.server.post('/identity/oidc/config', () => {
assert.notOk(true, 'request should not be made to issuer config endpoint');
});
this.server.post(configUrl('azure', this.id), () => {
assert.notOk(
true,
'post request was made to config/ when user canceled out of flow. test should fail.'
);
});
await this.renderComponent();
await click(SES.wif.accessType('wif'));
assert
.dom(GENERAL.inputByAttr('issuer'))
.hasAttribute('placeholder', 'https://vault-test.com', 'shows issuer placeholder');
assert.dom(GENERAL.inputByAttr('issuer')).hasValue('', 'shows issuer is empty when not passed');
await fillIn(GENERAL.inputByAttr('issuer'), 'http://bar.foo');
await click(GENERAL.saveButton);
assert.dom(SES.wif.issuerWarningMessage).exists('issuer modal exists');
assert
.dom(SES.wif.issuerWarningMessage)
.hasText(
`You are updating the global issuer config. This will overwrite Vault's current issuer and may affect other configurations using this value. Continue?`,
'modal shows message about overwriting value without the noRead: "if it exists" adage'
);
await click(SES.wif.issuerWarningCancel);
assert.dom(SES.wif.issuerWarningMessage).doesNotExist('issuer modal is removed on cancel');
assert.true(this.flashDangerSpy.notCalled, 'No danger flash messages called.');
assert.true(this.flashSuccessSpy.notCalled, 'No success flash messages called.');
assert.true(this.transitionStub.notCalled, 'Does not redirect');
});
test('it shows modal when updating issuer and calls correct APIs on save', async function (assert) {
const newIssuer = `http://bar.${uuidv4()}`;
this.server.post('/identity/oidc/config', (schema, req) => {
const payload = JSON.parse(req.requestBody);
assert.deepEqual(payload, { issuer: newIssuer }, 'payload for issuer is correct');
return {
id: 'identity-oidc-config', // id needs to match the id on secret-engine-helpers createIssuerConfig
data: null,
warnings: [
'If "issuer" is set explicitly, all tokens must be validated against that address, including those issued by secondary clusters. Setting issuer to "" will restore the default behavior of using the cluster\'s api_addr as the issuer.',
],
};
});
this.server.post(configUrl('azure', this.id), () => {
assert.notOk(true, 'skips request to config because the model was not changed');
});
await this.renderComponent();
await click(SES.wif.accessType('wif'));
assert.dom(GENERAL.inputByAttr('issuer')).hasValue('', 'issuer defaults to empty string');
await fillIn(GENERAL.inputByAttr('issuer'), newIssuer);
await click(GENERAL.saveButton);
assert.dom(SES.wif.issuerWarningMessage).exists('issue warning modal exists');
await click(SES.wif.issuerWarningSave);
assert.true(this.flashDangerSpy.notCalled, 'No danger flash messages called.');
assert.true(
this.flashSuccessSpy.calledWith('Issuer saved successfully'),
'Success flash message called for Azure issuer'
);
assert.true(
this.transitionStub.calledWith('vault.cluster.secrets.backend.configuration', this.id),
'Transitioned to the configuration index route.'
);
});
test('shows modal when modifying the issuer, has correct payload, and shows flash message on fail', async function (assert) {
assert.expect(7);
this.issuer = 'http://foo.bar';
this.server.post(configUrl('azure', this.id), () => {
assert.true(
true,
'post request was made to azure config when unsetting the issuer. test should pass.'
);
});
this.server.post('/identity/oidc/config', (_, req) => {
const payload = JSON.parse(req.requestBody);
assert.deepEqual(payload, { issuer: this.issuer }, 'correctly sets the issuer');
return overrideResponse(403);
});
await this.renderComponent();
await click(SES.wif.accessType('wif'));
assert.dom(GENERAL.inputByAttr('issuer')).hasValue('');
await fillIn(GENERAL.inputByAttr('issuer'), this.issuer);
await fillIn(GENERAL.inputByAttr('identityTokenAudience'), 'some-value');
await click(GENERAL.saveButton);
assert.dom(SES.wif.issuerWarningMessage).exists('issuer warning modal exists');
await click(SES.wif.issuerWarningSave);
assert.true(
this.flashDangerSpy.calledWith('Issuer was not saved: permission denied'),
'shows danger flash for issuer save'
);
assert.true(
this.flashSuccessSpy.calledWith(`Successfully saved ${this.id}'s configuration.`),
"calls the config flash message not the issuer's"
);
assert.ok(
this.transitionStub.calledWith('vault.cluster.secrets.backend.configuration', this.id),
'Transitioned to the configuration index route.'
);
});
});
});
module('isCommunity', function (hooks) {
hooks.beforeEach(function () {
this.version.type = 'community';
});
test('it renders fields', async function (assert) {
assert.expect(9);
await this.renderComponent();
assert.dom(SES.configureForm).exists('t lands on the Azure configuration form');
assert
.dom(SES.wif.accessTypeSection)
.doesNotExist('Access type section does not render for a community user');
// check all the form fields are present
for (const key of expectedConfigKeys('azure', true)) {
assert.dom(GENERAL.inputByAttr(key)).exists(`${key} shows for azure account creds section.`);
}
assert.dom(GENERAL.inputByAttr('issuer')).doesNotExist();
});
test('it does not send issuer on save', async function (assert) {
assert.expect(4);
await this.renderComponent();
this.server.post(configUrl('azure', this.id), () => {
assert.true(true, 'post request was made to config. test should pass.');
});
this.server.post('/identity/oidc/config', () => {
throw new Error('post request was incorrectly made to update issuer');
});
await fillInAzureConfig('azure');
await click(GENERAL.saveButton);
assert.dom(SES.wif.issuerWarningMessage).doesNotExist('modal should not render');
assert.true(
this.flashSuccessSpy.calledWith(`Successfully saved ${this.id}'s configuration.`),
'Flash message shows that config was saved even if issuer was not.'
);
assert.ok(
this.transitionStub.calledWith('vault.cluster.secrets.backend.configuration', this.id),
'Transitioned to the configuration index route.'
);
});
});
});
module('Edit view', function () {
module('isEnterprise', function (hooks) {
hooks.beforeEach(function () {
this.version.type = 'enterprise';
});
test('it defaults to Azure accessType if Azure account fields are already set', async function (assert) {
this.config = createConfig(this.store, this.id, 'azure');
await this.renderComponent();
assert.dom(SES.wif.accessType('azure')).isChecked('Azure accessType is checked');
assert.dom(SES.wif.accessType('azure')).isDisabled('Azure accessType is disabled');
assert.dom(SES.wif.accessType('wif')).isNotChecked('WIF accessType is not checked');
assert.dom(SES.wif.accessType('wif')).isDisabled('WIF accessType is disabled');
assert
.dom(SES.wif.accessTypeSubtext)
.hasText('You cannot edit Access Type if you have already saved access credentials.');
});
test('it defaults to WIF accessType if WIF fields are already set', async function (assert) {
this.config = createConfig(this.store, this.id, 'azure-wif');
await this.renderComponent();
assert.dom(SES.wif.accessType('wif')).isChecked('WIF accessType is checked');
assert.dom(SES.wif.accessType('wif')).isDisabled('WIF accessType is disabled');
assert.dom(SES.wif.accessType('azure')).isNotChecked('azure accessType is not checked');
assert.dom(SES.wif.accessType('azure')).isDisabled('azure accessType is disabled');
assert.dom(GENERAL.inputByAttr('identityTokenAudience')).hasValue(this.config.identityTokenAudience);
assert
.dom(SES.wif.accessTypeSubtext)
.hasText('You cannot edit Access Type if you have already saved access credentials.');
assert.dom(GENERAL.ttl.input('Identity token TTL')).hasValue('2'); // 7200 on payload is 2hrs in ttl picker
});
test('it renders issuer if global issuer is already set', async function (assert) {
this.config = createConfig(this.store, this.id, 'azure-wif');
this.issuerConfig = createConfig(this.store, this.id, 'issuer');
this.issuerConfig.issuer = 'https://foo-bar-blah.com';
await this.renderComponent();
assert.dom(SES.wif.accessType('wif')).isChecked('WIF accessType is checked');
assert.dom(SES.wif.accessType('wif')).isDisabled('WIF accessType is disabled');
assert
.dom(GENERAL.inputByAttr('issuer'))
.hasValue(
this.issuerConfig.issuer,
`it has the global issuer value of ${this.issuerConfig.issuer}`
);
});
test('it allows you to change accessType if record does not have wif or azure values already set', async function (assert) {
// the model does not have to be new for a user to see the option to change the access type.
// the access type is only disabled if the model has values already set for access type fields.
this.config = createConfig(this.store, this.id, 'azure-generic');
await this.renderComponent();
assert.dom(SES.wif.accessType('wif')).isNotDisabled('WIF accessType is NOT disabled');
assert.dom(SES.wif.accessType('azure')).isNotDisabled('Azure accessType is NOT disabled');
});
test('it shows previously saved config information', async function (assert) {
this.config = createConfig(this.store, this.id, 'azure-generic');
await this.renderComponent();
assert.dom(GENERAL.inputByAttr('subscriptionId')).hasValue(this.config.subscriptionId);
assert.dom(GENERAL.inputByAttr('clientId')).hasValue(this.config.clientId);
assert.dom(GENERAL.inputByAttr('tenantId')).hasValue(this.config.tenantId);
assert
.dom(GENERAL.inputByAttr('clientSecret'))
.hasValue('**********', 'clientSecret is masked on edit the value');
});
test('it requires a double click to change the client secret', async function (assert) {
this.config = createConfig(this.store, this.id, 'azure');
await this.renderComponent();
this.server.post(configUrl('azure', this.id), (schema, req) => {
const payload = JSON.parse(req.requestBody);
assert.strictEqual(
payload.client_secret,
'new-secret',
'post request was made to azure/config with the updated client_secret.'
);
});
await click(GENERAL.enableField('clientSecret'));
await click('[data-test-button="toggle-masked"]');
await fillIn(GENERAL.inputByAttr('clientSecret'), 'new-secret');
await click(GENERAL.saveButton);
});
});
module('isCommunity', function (hooks) {
hooks.beforeEach(function () {
this.version.type = 'community';
});
test('it does not show access types but defaults to Azure account fields', async function (assert) {
this.config = createConfig(this.store, this.id, 'azure-generic');
await this.renderComponent();
assert.dom(SES.wif.accessTypeSection).doesNotExist('Access type section does not render');
assert.dom(GENERAL.inputByAttr('clientId')).hasValue(this.config.clientId);
assert.dom(GENERAL.inputByAttr('subscriptionId')).hasValue(this.config.subscriptionId);
assert.dom(GENERAL.inputByAttr('tenantId')).hasValue(this.config.tenantId);
});
});
});
});

View File

@@ -0,0 +1,866 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import { module, test } from 'qunit';
import sinon from 'sinon';
import { setupRenderingTest } from 'vault/tests/helpers';
import { GENERAL } from 'vault/tests/helpers/general-selectors';
import { SECRET_ENGINE_SELECTORS as SES } from 'vault/tests/helpers/secret-engine/secret-engine-selectors';
import { render, click, fillIn } from '@ember/test-helpers';
import { setupMirage } from 'ember-cli-mirage/test-support';
import { v4 as uuidv4 } from 'uuid';
import { hbs } from 'ember-cli-htmlbars';
import { overrideResponse } from 'vault/tests/helpers/stubs';
import {
expectedConfigKeys,
createConfig,
configUrl,
fillInAzureConfig,
fillInAwsConfig,
} from 'vault/tests/helpers/secret-engine/secret-engine-helpers';
import { capabilitiesStub } from 'vault/tests/helpers/stubs';
import { WIF_ENGINES, allEngines } from 'vault/helpers/mountable-secret-engines';
import waitForError from 'vault/tests/helpers/wait-for-error';
const allEnginesArray = allEngines(); // saving as const so we don't invoke the method multiple times in the for loop
module('Integration | Component | SecretEngine::ConfigureWif', function (hooks) {
setupRenderingTest(hooks);
setupMirage(hooks);
hooks.beforeEach(function () {
this.store = this.owner.lookup('service:store');
this.version = this.owner.lookup('service:version');
this.flashMessages = this.owner.lookup('service:flash-messages');
this.flashMessages.registerTypes(['success', 'danger']);
this.flashSuccessSpy = sinon.spy(this.flashMessages, 'success');
this.flashDangerSpy = sinon.spy(this.flashMessages, 'danger');
this.transitionStub = sinon.stub(this.owner.lookup('service:router'), 'transitionTo');
this.uid = uuidv4();
// stub capabilities so that by default user can read and update issuer
this.server.post('/sys/capabilities-self', () => capabilitiesStub('identity/oidc/config', ['sudo']));
});
module('Create view', function () {
module('isEnterprise', function (hooks) {
hooks.beforeEach(function () {
this.version.type = 'enterprise';
});
for (const type of WIF_ENGINES) {
test(`${type}: it renders default fields`, async function (assert) {
this.id = `${type}-${this.uid}`;
this.displayName = allEnginesArray.find((engine) => engine.type === type)?.displayName;
this.issuerConfig = createConfig(this.store, this.id, 'issuer');
this.mountConfigModel =
type === 'aws'
? this.store.createRecord('aws/root-config')
: this.store.createRecord(`${type}/config`);
this.additionalConfigModel = type === 'aws' ? this.store.createRecord('aws/lease-config') : null;
this.mountConfigModel.backend = this.id;
this.additionalConfigModel ? (this.additionalConfigModel.backend = this.id) : null; // Add backend to the configs because it's not on the testing snapshot (would come from url)
this.type = type;
await render(hbs`
<SecretEngine::ConfigureWif @backendPath={{this.id}} @displayName={{this.displayName}} @type={{this.type}} @mountConfigModel={{this.mountConfigModel}} @issuerConfig={{this.issuerConfig}} @additionalConfigModel={{this.additionalConfigModel}}/>
`);
assert.dom(SES.configureForm).exists(`it lands on the ${type} configuration form`);
assert.dom(SES.wif.accessType(type)).isChecked(`defaults to showing ${type} access type checked`);
assert.dom(SES.wif.accessType('wif')).isNotChecked('wif access type is not checked');
// toggle grouped fields if it exists
const toggleGroup = document.querySelector('[data-test-toggle-group]');
toggleGroup ? await click(toggleGroup) : null;
for (const key of expectedConfigKeys(type, true)) {
assert
.dom(GENERAL.inputByAttr(key))
.exists(
`${key} shows for ${type} configuration create section when wif is not the access type`
);
}
assert
.dom(GENERAL.inputByAttr('issuer'))
.doesNotExist(`for ${type}, the issuer does not show when wif is not the access type`);
});
}
for (const type of WIF_ENGINES) {
test(`${type}: it renders wif fields when user selects wif access type`, async function (assert) {
this.id = `${type}-${this.uid}`;
this.displayName = allEnginesArray.find((engine) => engine.type === type)?.displayName;
this.issuerConfig = createConfig(this.store, this.id, 'issuer');
this.mountConfigModel =
type === 'aws'
? this.store.createRecord('aws/root-config')
: this.store.createRecord(`${type}/config`);
this.additionalConfigModel = type === 'aws' ? this.store.createRecord('aws/lease-config') : null;
this.mountConfigModel.backend = this.id;
this.additionalConfigModel ? (this.additionalConfigModel.backend = this.id) : null;
this.type = type;
await render(hbs`
<SecretEngine::ConfigureWif @backendPath={{this.id}} @displayName={{this.displayName}} @type={{this.type}} @mountConfigModel={{this.mountConfigModel}} @issuerConfig={{this.issuerConfig}} @additionalConfigModel={{this.additionalConfigModel}}/>
`);
await click(SES.wif.accessType('wif'));
// check for the wif fields only
for (const key of expectedConfigKeys(`${type}-wif`, true)) {
if (key === 'Identity token TTL') {
assert.dom(GENERAL.ttl.toggle(key)).exists(`${key} shows for ${type} wif section.`);
} else {
assert.dom(GENERAL.inputByAttr(key)).exists(`${key} shows for ${type} wif section.`);
}
}
assert.dom(GENERAL.inputByAttr('issuer')).exists(`issuer shows for ${type} wif section.`);
});
}
/* This module covers code that is the same for all engines. We run them once against one of the engines.*/
module('Engine agnostic', function () {
test('it transitions without sending a config or issuer payload on cancel', async function (assert) {
assert.expect(3);
this.id = `azure-${this.uid}`;
this.displayName = 'Azure';
this.issuerConfig = createConfig(this.store, this.id, 'issuer');
this.mountConfigModel = this.store.createRecord('azure/config');
this.mountConfigModel.backend = this.id;
await render(hbs`
<SecretEngine::ConfigureWif @backendPath={{this.id}} @displayName={{this.displayName}} @type='azure' @mountConfigModel={{this.mountConfigModel}} @issuerConfig={{this.issuerConfig}}/>
`);
this.server.post(configUrl('azure', this.id), () => {
throw new Error(
`Request was made to post the config when it should not have been because the user canceled out of the flow.`
);
});
this.server.post('/identity/oidc/config', () => {
throw new Error(
`Request was made to save the issuer when it should not have been because the user canceled out of the flow.`
);
});
await fillInAzureConfig('withWif');
await click(GENERAL.cancelButton);
assert.true(this.flashDangerSpy.notCalled, 'No danger flash messages called.');
assert.true(this.flashSuccessSpy.notCalled, 'No success flash messages called.');
assert.true(
this.transitionStub.calledWith('vault.cluster.secrets.backend.configuration', this.id),
'Transitioned to the configuration index route.'
);
});
test('it throws an error if the getter isWifPluginConfigured is not defined on the model', async function (assert) {
const promise = waitForError();
this.id = `azure-${this.uid}`;
this.displayName = 'Azure';
// creating a config that exists but will not have the attribute isWifPluginConfigured on it
this.mountConfigModel = this.store.createRecord('ssh/ca-config', { backend: this.id });
await render(hbs`
<SecretEngine::ConfigureWif @backendPath={{this.id}} @displayName={{this.displayName}} @type='azure' @mountConfigModel={{this.mountConfigModel}} @issuerConfig={{this.issuerConfig}}/>
`);
const err = await promise;
assert.true(
err.message.includes(
`'isWifPluginConfigured' is required to be defined on the config model. Must return a boolean.`
),
'asserts without isWifPluginConfigured'
);
});
test('it allows user to submit the config even if API error occurs on issuer config', async function (assert) {
this.id = `aws-${this.uid}`;
this.displayName = 'AWS';
this.issuerConfig = createConfig(this.store, this.id, 'issuer');
this.mountConfigModel = this.store.createRecord('aws/root-config');
this.additionalConfigModel = this.store.createRecord('aws/lease-config');
this.mountConfigModel.backend = this.additionalConfigModel.backend = this.id;
await render(hbs`
<SecretEngine::ConfigureWif @backendPath={{this.id}} @displayName={{this.displayName}} @type='aws' @mountConfigModel={{this.mountConfigModel}} @issuerConfig={{this.issuerConfig}} @additionalConfigModel={{this.additionalConfigModel}}/>
`);
this.server.post(configUrl('aws', this.id), () => {
assert.true(true, 'post request was made to config/root when issuer failed. test should pass.');
});
this.server.post('/identity/oidc/config', () => {
return overrideResponse(400, { errors: ['bad request'] });
});
await fillInAwsConfig('withWif');
await click(GENERAL.saveButton);
await click(SES.wif.issuerWarningSave);
assert.true(
this.flashDangerSpy.calledWith('Issuer was not saved: bad request'),
'Flash message shows that issuer was not saved'
);
assert.true(
this.flashSuccessSpy.calledWith(`Successfully saved ${this.id}'s configuration.`),
'Flash message shows that root was saved even if issuer was not'
);
assert.true(
this.transitionStub.calledWith('vault.cluster.secrets.backend.configuration', this.id),
'Transitioned to the configuration index route.'
);
});
test('it surfaces the API error if config save fails, and prevents the user from transitioning', async function (assert) {
this.id = `aws-${this.uid}`;
this.displayName = 'AWS';
this.issuerConfig = createConfig(this.store, this.id, 'issuer');
this.mountConfigModel = this.store.createRecord('aws/root-config');
this.additionalConfigModel = this.store.createRecord('aws/lease-config');
this.mountConfigModel.backend = this.additionalConfigModel.backend = this.id;
await render(hbs`
<SecretEngine::ConfigureWif @backendPath={{this.id}} @displayName={{this.displayName}} @type='aws' @mountConfigModel={{this.mountConfigModel}} @issuerConfig={{this.issuerConfig}} @additionalConfigModel={{this.additionalConfigModel}}/>
`);
this.server.post(configUrl('aws', this.id), () => {
return overrideResponse(400, { errors: ['bad request'] });
});
this.server.post(configUrl('aws-lease', this.id), () => {
assert.true(
true,
'post request was made to config/lease when config/root failed. test should pass.'
);
});
// fill in both lease and root endpoints to ensure that both payloads are attempted to be sent
await fillInAwsConfig('withAccess');
await fillInAwsConfig('withLease');
await click(GENERAL.saveButton);
assert.dom(GENERAL.messageError).exists('API error surfaced to user');
assert.dom(GENERAL.inlineError).exists('User shown inline error message');
});
});
module('Azure specific', function (hooks) {
hooks.beforeEach(function () {
this.id = `azure-${this.uid}`;
this.displayName = 'Azure';
this.issuerConfig = createConfig(this.store, this.id, 'issuer');
this.mountConfigModel = this.store.createRecord('azure/config');
this.mountConfigModel.backend = this.id;
});
test('it clears access type inputs after toggling accessType', async function (assert) {
await render(hbs`
<SecretEngine::ConfigureWif @backendPath={{this.id}} @displayName={{this.displayName}} @type='azure' @mountConfigModel={{this.mountConfigModel}} @issuerConfig={{this.issuerConfig}}/>
`);
await fillInAzureConfig('azure');
await click(SES.wif.accessType('wif'));
await fillInAzureConfig('withWif');
await click(SES.wif.accessType('azure'));
assert
.dom(GENERAL.toggleInput('Root password TTL'))
.isNotChecked('rootPasswordTtl is cleared after toggling accessType');
assert
.dom(GENERAL.inputByAttr('clientSecret'))
.hasValue('', 'clientSecret is cleared after toggling accessType');
await click(SES.wif.accessType('wif'));
assert
.dom(GENERAL.inputByAttr('issuer'))
.hasValue('', 'issuer shows no value after toggling accessType');
assert
.dom(GENERAL.inputByAttr('issuer'))
.hasAttribute(
'placeholder',
'https://vault-test.com',
'issuer shows no value after toggling accessType'
);
assert
.dom(GENERAL.inputByAttr('identityTokenAudience'))
.hasValue('', 'idTokenAudience is cleared after toggling accessType');
assert
.dom(GENERAL.toggleInput('Identity token TTL'))
.isNotChecked('identityTokenTtl is cleared after toggling accessType');
});
test('it shows the correct access type subtext', async function (assert) {
await render(hbs`
<SecretEngine::ConfigureWif @backendPath={{this.id}} @displayName={{this.displayName}} @type='azure' @mountConfigModel={{this.mountConfigModel}} @issuerConfig={{this.issuerConfig}}/>
`);
assert
.dom(SES.wif.accessTypeSubtext)
.hasText(
'Choose the way to configure access to Azure. Access can be configured either using Azure account credentials or with the Plugin Workload Identity Federation (WIF).'
);
});
test('it does not show aws specific note', async function (assert) {
await render(hbs`
<SecretEngine::ConfigureWif @backendPath={{this.id}} @displayName={{this.displayName}} @type='azure' @mountConfigModel={{this.mountConfigModel}} @issuerConfig={{this.issuerConfig}}/>
`);
assert
.dom(SES.configureNote('azure'))
.doesNotExist('Note specific to AWS does not show for Azure secret engine when configuring.');
});
});
module('AWS specific', function (hooks) {
hooks.beforeEach(function () {
this.id = `aws-${this.uid}`;
this.displayName = 'AWS';
this.issuerConfig = createConfig(this.store, this.id, 'issuer');
this.mountConfigModel = this.store.createRecord('aws/root-config');
this.additionalConfigModel = this.store.createRecord('aws/lease-config');
this.mountConfigModel.backend = this.additionalConfigModel.backend = this.id;
});
test('it clears access type inputs after toggling accessType', async function (assert) {
await render(hbs`
<SecretEngine::ConfigureWif @backendPath={{this.id}} @displayName={{this.displayName}} @type='aws' @mountConfigModel={{this.mountConfigModel}} @issuerConfig={{this.issuerConfig}}/>
`);
await fillInAwsConfig('aws');
await click(SES.wif.accessType('wif'));
await fillInAwsConfig('with-wif');
await click(SES.wif.accessType('aws'));
assert
.dom(GENERAL.inputByAttr('accessKey'))
.hasValue('', 'accessKey is cleared after toggling accessType');
await click(SES.wif.accessType('wif'));
assert
.dom(GENERAL.inputByAttr('issuer'))
.hasValue('', 'issuer shows no value after toggling accessType');
assert
.dom(GENERAL.inputByAttr('issuer'))
.hasAttribute(
'placeholder',
'https://vault-test.com',
'issuer shows no value after toggling accessType'
);
assert
.dom(GENERAL.inputByAttr('identityTokenAudience'))
.hasValue('', 'idTokenAudience is cleared after toggling accessType');
assert
.dom(GENERAL.toggleInput('Identity token TTL'))
.isNotChecked('identityTokenTtl is cleared after toggling accessType');
});
test('it shows the correct access type subtext', async function (assert) {
await render(hbs`
<SecretEngine::ConfigureWif @backendPath={{this.id}} @displayName={{this.displayName}} @type='aws' @mountConfigModel={{this.mountConfigModel}} @issuerConfig={{this.issuerConfig}}/>
`);
assert
.dom(SES.wif.accessTypeSubtext)
.hasText(
'Choose the way to configure access to AWS. Access can be configured either using IAM access keys or with the Plugin Workload Identity Federation (WIF).'
);
});
test('it shows validation error if default lease is entered but max lease is not', async function (assert) {
await render(hbs`
<SecretEngine::ConfigureWif @backendPath={{this.id}} @displayName={{this.displayName}} @type='aws' @mountConfigModel={{this.mountConfigModel}} @issuerConfig={{this.issuerConfig}} @additionalConfigModel={{this.additionalConfigModel}}/>
`);
this.server.post(configUrl('aws-lease', this.id), () => {
throw new Error(
`Request was made to post the config/lease when it should not have been because no data was changed.`
);
});
this.server.post(configUrl('aws', this.id), () => {
throw new Error(
`Request was made to post the config/root when it should not have been because no data was changed.`
);
});
await click(GENERAL.ttl.toggle('Default Lease TTL'));
await fillIn(GENERAL.ttl.input('Default Lease TTL'), '33');
await click(GENERAL.saveButton);
assert
.dom(GENERAL.inlineError)
.hasText('Lease TTL and Max Lease TTL are both required if one of them is set.');
assert.dom(SES.configureForm).exists('remains on the configuration form');
});
test('it allows user to submit root config even if API error occurs on config/lease config', async function (assert) {
await render(hbs`
<SecretEngine::ConfigureWif @backendPath={{this.id}} @displayName={{this.displayName}} @type='aws' @mountConfigModel={{this.mountConfigModel}} @issuerConfig={{this.issuerConfig}} @additionalConfigModel={{this.additionalConfigModel}}/>
`);
this.server.post(configUrl('aws', this.id), () => {
assert.true(
true,
'post request was made to config/root when config/lease failed. test should pass.'
);
});
this.server.post(configUrl('aws-lease', this.id), () => {
return overrideResponse(400, { errors: ['bad request!!'] });
});
// fill in both lease and root endpoints to ensure that both payloads are attempted to be sent
await fillInAwsConfig('withAccess');
await fillInAwsConfig('withLease');
await click(GENERAL.saveButton);
assert.true(
this.flashDangerSpy.calledWith('Lease configuration was not saved: bad request!!'),
'Flash message shows that lease was not saved.'
);
assert.true(
this.transitionStub.calledWith('vault.cluster.secrets.backend.configuration', this.id),
'Transitioned to the configuration index route.'
);
});
test('it transitions without sending a lease, root, or issuer payload on cancel', async function (assert) {
await render(hbs`
<SecretEngine::ConfigureWif @backendPath={{this.id}} @displayName={{this.displayName}} @type='aws' @mountConfigModel={{this.mountConfigModel}} @issuerConfig={{this.issuerConfig}} @additionalConfigModel={{this.additionalConfigModel}}/>
`);
this.server.post(configUrl('aws', this.id), () => {
throw new Error(
`Request was made to post the config/root when it should not have been because the user canceled out of the flow.`
);
});
this.server.post(configUrl('aws-lease', this.id), () => {
throw new Error(
`Request was made to post the config/lease when it should not have been because the user canceled out of the flow.`
);
});
this.server.post('/identity/oidc/config', () => {
throw new Error(
`Request was made to post the identity/oidc/config when it should not have been because the user canceled out of the flow.`
);
});
// fill in both lease and root endpoints to ensure that both payloads are attempted to be sent
await fillInAwsConfig('withWif');
await fillInAwsConfig('withLease');
await click(GENERAL.cancelButton);
assert.true(this.flashDangerSpy.notCalled, 'No danger flash messages called.');
assert.true(this.flashSuccessSpy.notCalled, 'No success flash messages called.');
assert.true(
this.transitionStub.calledWith('vault.cluster.secrets.backend.configuration', this.id),
'Transitioned to the configuration index route.'
);
});
test('it does show aws specific note', async function (assert) {
await render(hbs`
<SecretEngine::ConfigureWif @backendPath={{this.id}} @displayName={{this.displayName}} @type='aws' @mountConfigModel={{this.mountConfigModel}} @issuerConfig={{this.issuerConfig}} @additionalConfigModel={{this.additionalConfigModel}}/>
`);
assert.dom(SES.configureNote('aws')).exists('Note specific to AWS does show when configuring.');
});
});
module('Issuer field tests', function (hooks) {
hooks.beforeEach(function () {
this.id = `azure-${this.uid}`;
this.displayName = 'Azure';
this.issuerConfig = createConfig(this.store, this.id, 'issuer');
this.issuerConfig.queryIssuerError = true;
this.mountConfigModel = this.store.createRecord('azure/config');
this.mountConfigModel.backend = this.id;
});
test('if issuer API error and user changes issuer value, shows specific warning message', async function (assert) {
await render(hbs`
<SecretEngine::ConfigureWif @backendPath={{this.id}} @displayName={{this.displayName}} @type='azure' @mountConfigModel={{this.mountConfigModel}} @issuerConfig={{this.issuerConfig}}/>
`);
await click(SES.wif.accessType('wif'));
await fillIn(GENERAL.inputByAttr('issuer'), 'http://change.me.no.read');
await click(GENERAL.saveButton);
assert
.dom(SES.wif.issuerWarningMessage)
.hasText(
`You are updating the global issuer config. This will overwrite Vault's current issuer if it exists and may affect other configurations using this value. Continue?`,
'modal shows message about overwriting value if it exists'
);
});
test('it shows placeholder issuer, and does not call APIs on canceling out of issuer modal', async function (assert) {
this.server.post('/identity/oidc/config', () => {
throw new Error(
'Request was made to post the identity/oidc/config when it should not have been because user canceled out of the modal.'
);
});
this.server.post(configUrl('azure', this.id), () => {
throw new Error(
`Request was made to post the config when it should not have been because the user canceled out of the flow.`
);
});
this.issuerConfig.queryIssuerError = false;
await render(hbs`
<SecretEngine::ConfigureWif @backendPath={{this.id}} @displayName={{this.displayName}} @type='azure' @mountConfigModel={{this.mountConfigModel}} @issuerConfig={{this.issuerConfig}}/>
`);
await click(SES.wif.accessType('wif'));
assert
.dom(GENERAL.inputByAttr('issuer'))
.hasAttribute('placeholder', 'https://vault-test.com', 'shows issuer placeholder');
assert.dom(GENERAL.inputByAttr('issuer')).hasValue('', 'shows issuer is empty when not passed');
await fillIn(GENERAL.inputByAttr('issuer'), 'http://bar.foo');
await click(GENERAL.saveButton);
assert.dom(SES.wif.issuerWarningMessage).exists('issuer modal exists');
assert
.dom(SES.wif.issuerWarningMessage)
.hasText(
`You are updating the global issuer config. This will overwrite Vault's current issuer and may affect other configurations using this value. Continue?`,
'modal shows message about overwriting value without the noRead: "if it exists" adage'
);
await click(SES.wif.issuerWarningCancel);
assert.dom(SES.wif.issuerWarningMessage).doesNotExist('issuer modal is removed on cancel');
assert.true(this.flashDangerSpy.notCalled, 'No danger flash messages called.');
assert.true(this.flashSuccessSpy.notCalled, 'No success flash messages called.');
assert.true(this.transitionStub.notCalled, 'Does not redirect');
});
test('it shows modal when updating issuer and calls correct APIs on save', async function (assert) {
const newIssuer = `http://bar.${uuidv4()}`;
this.server.post('/identity/oidc/config', (schema, req) => {
const payload = JSON.parse(req.requestBody);
assert.deepEqual(payload, { issuer: newIssuer }, 'payload for issuer is correct');
return {
id: 'identity-oidc-config', // id needs to match the id on secret-engine-helpers createIssuerConfig
data: null,
warnings: [
'If "issuer" is set explicitly, all tokens must be validated against that address, including those issued by secondary clusters. Setting issuer to "" will restore the default behavior of using the cluster\'s api_addr as the issuer.',
],
};
});
this.server.post(configUrl('azure', this.id), () => {
throw new Error(
`Request was made to post the config when it should not have been because no data was changed.`
);
});
await render(hbs`
<SecretEngine::ConfigureWif @backendPath={{this.id}} @displayName={{this.displayName}} @type='azure' @mountConfigModel={{this.mountConfigModel}} @issuerConfig={{this.issuerConfig}}/>
`);
await click(SES.wif.accessType('wif'));
assert.dom(GENERAL.inputByAttr('issuer')).hasValue('', 'issuer defaults to empty string');
await fillIn(GENERAL.inputByAttr('issuer'), newIssuer);
await click(GENERAL.saveButton);
assert.dom(SES.wif.issuerWarningMessage).exists('issuer warning modal exists');
await click(SES.wif.issuerWarningSave);
assert.true(this.flashDangerSpy.notCalled, 'No danger flash messages called.');
assert.true(
this.flashSuccessSpy.calledWith('Issuer saved successfully'),
'Success flash message called for issuer'
);
assert.true(
this.transitionStub.calledWith('vault.cluster.secrets.backend.configuration', this.id),
'Transitioned to the configuration index route.'
);
});
test('shows modal when modifying the issuer, has correct payload, and shows flash message on fail', async function (assert) {
assert.expect(7);
this.server.post(configUrl('azure', this.id), () => {
assert.true(
true,
'post request was made to azure config when unsetting the issuer. test should pass.'
);
});
this.server.post('/identity/oidc/config', (_, req) => {
const payload = JSON.parse(req.requestBody);
assert.deepEqual(payload, { issuer: 'http://foo.bar' }, 'correctly sets the issuer');
return overrideResponse(403);
});
await render(hbs`
<SecretEngine::ConfigureWif @backendPath={{this.id}} @displayName={{this.displayName}} @type='azure' @mountConfigModel={{this.mountConfigModel}} @issuerConfig={{this.issuerConfig}}/>
`);
await click(SES.wif.accessType('wif'));
assert.dom(GENERAL.inputByAttr('issuer')).hasValue('');
await fillIn(GENERAL.inputByAttr('issuer'), 'http://foo.bar');
await fillIn(GENERAL.inputByAttr('identityTokenAudience'), 'some-value');
await click(GENERAL.saveButton);
assert.dom(SES.wif.issuerWarningMessage).exists('issuer warning modal exists');
await click(SES.wif.issuerWarningSave);
assert.true(
this.flashDangerSpy.calledWith('Issuer was not saved: permission denied'),
'shows danger flash for issuer save'
);
assert.true(
this.flashSuccessSpy.calledWith(`Successfully saved ${this.id}'s configuration.`),
"calls the config flash message not the issuer's"
);
assert.true(
this.transitionStub.calledWith('vault.cluster.secrets.backend.configuration', this.id),
'Transitioned to the configuration index route.'
);
});
test('it does not clear global issuer when toggling accessType', async function (assert) {
await render(hbs`
<SecretEngine::ConfigureWif @backendPath={{this.id}} @displayName={{this.displayName}} @type='azure' @mountConfigModel={{this.mountConfigModel}} @issuerConfig={{this.issuerConfig}}/>
`);
await click(SES.wif.accessType('wif'));
assert
.dom(GENERAL.inputByAttr('issuer'))
.hasValue(this.issuerConfig.issuer, 'issuer is what is sent in by the model on first load');
await fillIn(GENERAL.inputByAttr('issuer'), 'http://ive-changed');
await click(SES.wif.accessType('azure'));
await click(SES.wif.accessType('wif'));
assert
.dom(GENERAL.inputByAttr('issuer'))
.hasValue(
this.issuerConfig.issuer,
'issuer value is still the same global value after toggling accessType'
);
});
});
});
module('isCommunity', function (hooks) {
hooks.beforeEach(function () {
this.version.type = 'community';
});
for (const type of WIF_ENGINES) {
test(`${type}: it renders fields`, async function (assert) {
this.id = `${type}-${this.uid}`;
this.displayName = allEnginesArray.find((engine) => engine.type === type)?.displayName;
this.issuerConfig = createConfig(this.store, this.id, 'issuer');
this.mountConfigModel =
type === 'aws'
? this.store.createRecord('aws/root-config')
: type === 'ssh'
? this.store.createRecord('ssh/ca-config')
: this.store.createRecord(`${type}/config`);
this.additionalConfigModel = type === 'aws' ? this.store.createRecord('aws/lease-config') : null;
this.mountConfigModel.backend = this.id;
this.additionalConfigModel ? (this.additionalConfigModel.backend = this.id) : null;
this.type = type;
await render(hbs`
<SecretEngine::ConfigureWif @backendPath={{this.id}} @displayName={{this.displayName}} @type={{this.type}} @mountConfigModel={{this.mountConfigModel}} @issuerConfig={{this.issuerConfig}} @additionalConfigModel={{this.additionalConfigModel}}/>
`);
assert.dom(SES.configureForm).exists(`lands on the ${type} configuration form`);
assert
.dom(SES.wif.accessTypeSection)
.doesNotExist('Access type section does not render for a community user');
// toggle grouped fields if it exists
const toggleGroup = document.querySelector('[data-test-toggle-group]');
toggleGroup ? await click(toggleGroup) : null;
// check all the form fields are present
for (const key of expectedConfigKeys(type, true)) {
assert.dom(GENERAL.inputByAttr(key)).exists(`${key} shows for ${type} account access section.`);
}
assert.dom(GENERAL.inputByAttr('issuer')).doesNotExist();
});
}
});
});
module('Edit view', function () {
module('isEnterprise', function (hooks) {
hooks.beforeEach(function () {
this.version.type = 'enterprise';
});
for (const type of WIF_ENGINES) {
test(`${type}: it defaults to WIF accessType if WIF fields are already set`, async function (assert) {
this.id = `${type}-${this.uid}`;
this.displayName = allEnginesArray.find((engine) => engine.type === type)?.displayName;
this.mountConfigModel = createConfig(this.store, this.id, `${type}-wif`);
this.type = type;
await render(hbs`
<SecretEngine::ConfigureWif @backendPath={{this.id}} @displayName={{this.displayName}} @type={{this.type}} @mountConfigModel={{this.mountConfigModel}} @issuerConfig={{this.issuerConfig}} />
`);
assert.dom(SES.wif.accessType('wif')).isChecked('WIF accessType is checked');
assert.dom(SES.wif.accessType('wif')).isDisabled('WIF accessType is disabled');
assert.dom(SES.wif.accessType(type)).isNotChecked(`${type} accessType is not checked`);
assert.dom(SES.wif.accessType(type)).isDisabled(`${type} accessType is disabled`);
assert
.dom(GENERAL.inputByAttr('identityTokenAudience'))
.hasValue(this.mountConfigModel.identityTokenAudience);
assert
.dom(SES.wif.accessTypeSubtext)
.hasText('You cannot edit Access Type if you have already saved access credentials.');
assert.dom(GENERAL.ttl.input('Identity token TTL')).hasValue('2'); // 7200 on payload is 2hrs in ttl picker
});
}
for (const type of WIF_ENGINES) {
test(`${type}: it renders issuer if global issuer is already set`, async function (assert) {
this.id = `${type}-${this.uid}`;
this.displayName = allEnginesArray.find((engine) => engine.type === type)?.displayName;
this.mountConfigModel = createConfig(this.store, this.id, `${type}-wif`);
this.issuerConfig = createConfig(this.store, this.id, 'issuer');
this.issuerConfig.issuer = 'https://foo-bar-blah.com';
this.type = type;
await render(hbs`
<SecretEngine::ConfigureWif @backendPath={{this.id}} @displayName={{this.displayName}} @type={{this.type}} @mountConfigModel={{this.mountConfigModel}} @issuerConfig={{this.issuerConfig}}/>
`);
assert.dom(SES.wif.accessType('wif')).isChecked('WIF accessType is checked');
assert.dom(SES.wif.accessType('wif')).isDisabled('WIF accessType is disabled');
assert
.dom(GENERAL.inputByAttr('issuer'))
.hasValue(
this.issuerConfig.issuer,
`it has the global issuer value of ${this.issuerConfig.issuer}`
);
});
}
module('Azure specific', function (hooks) {
hooks.beforeEach(function () {
this.id = `azure-${this.uid}`;
this.mountConfigModel = createConfig(this.store, this.id, 'azure');
});
test('it defaults to Azure accessType if Azure account fields are already set', async function (assert) {
await render(hbs`
<SecretEngine::ConfigureWif @backendPath={{this.id}} @displayName='Azure' @type='azure' @mountConfigModel={{this.mountConfigModel}} @issuerConfig={{this.issuerConfig}}/>
`);
assert.dom(SES.wif.accessType('azure')).isChecked('Azure accessType is checked');
assert.dom(SES.wif.accessType('azure')).isDisabled('Azure accessType is disabled');
assert.dom(SES.wif.accessType('wif')).isNotChecked('WIF accessType is not checked');
assert.dom(SES.wif.accessType('wif')).isDisabled('WIF accessType is disabled');
assert
.dom(SES.wif.accessTypeSubtext)
.hasText('You cannot edit Access Type if you have already saved access credentials.');
});
test('it allows you to change accessType if record does not have wif or azure values already set', async function (assert) {
this.mountConfigModel = createConfig(this.store, this.id, 'azure-generic');
await render(hbs`
<SecretEngine::ConfigureWif @backendPath={{this.id}} @displayName='Azure' @type='azure' @mountConfigModel={{this.mountConfigModel}} @issuerConfig={{this.issuerConfig}}/>
`);
assert.dom(SES.wif.accessType('wif')).isNotDisabled('WIF accessType is NOT disabled');
assert.dom(SES.wif.accessType('azure')).isNotDisabled('Azure accessType is NOT disabled');
});
test('it shows previously saved config information', async function (assert) {
this.id = `azure-${this.uid}`;
this.mountConfigModel = createConfig(this.store, this.id, 'azure-generic');
await render(hbs`
<SecretEngine::ConfigureWif @backendPath={{this.id}} @displayName='Azure' @type='azure' @mountConfigModel={{this.mountConfigModel}} @issuerConfig={{this.issuerConfig}}/>
`);
assert.dom(GENERAL.inputByAttr('subscriptionId')).hasValue(this.mountConfigModel.subscriptionId);
assert.dom(GENERAL.inputByAttr('clientId')).hasValue(this.mountConfigModel.clientId);
assert.dom(GENERAL.inputByAttr('tenantId')).hasValue(this.mountConfigModel.tenantId);
assert
.dom(GENERAL.inputByAttr('clientSecret'))
.hasValue('**********', 'clientSecret is masked on edit the value');
});
test('it requires a double click to change the client secret', async function (assert) {
this.id = `azure-${this.uid}`;
await render(hbs`
<SecretEngine::ConfigureWif @backendPath={{this.id}} @displayName='Azure' @type='azure' @mountConfigModel={{this.mountConfigModel}} @issuerConfig={{this.issuerConfig}}/>
`);
this.server.post(configUrl('azure', this.id), (schema, req) => {
const payload = JSON.parse(req.requestBody);
assert.strictEqual(
payload.client_secret,
'new-secret',
'post request was made to azure/config with the updated client_secret.'
);
});
await click(GENERAL.enableField('clientSecret'));
await click('[data-test-button="toggle-masked"]');
await fillIn(GENERAL.inputByAttr('clientSecret'), 'new-secret');
await click(GENERAL.saveButton);
});
});
module('AWS specific', function (hooks) {
hooks.beforeEach(function () {
this.id = `aws-${this.uid}`;
this.mountConfigModel = createConfig(this.store, this.id, 'aws');
});
test('it defaults to IAM accessType if IAM fields are already set', async function (assert) {
await render(hbs`
<SecretEngine::ConfigureWif @backendPath={{this.id}} @displayName='AWS' @type='aws' @mountConfigModel={{this.mountConfigModel}} @issuerConfig={{this.issuerConfig}}/>
`);
assert.dom(SES.wif.accessType('aws')).isChecked('IAM accessType is checked');
assert.dom(SES.wif.accessType('aws')).isDisabled('IAM accessType is disabled');
assert.dom(SES.wif.accessType('wif')).isNotChecked('WIF accessType is not checked');
assert.dom(SES.wif.accessType('wif')).isDisabled('WIF accessType is disabled');
assert
.dom(SES.wif.accessTypeSubtext)
.hasText('You cannot edit Access Type if you have already saved access credentials.');
});
test('it allows you to change access type if record does not have wif or iam values already set', async function (assert) {
this.mountConfigModel = createConfig(this.store, this.id, 'aws-no-access');
await render(hbs`
<SecretEngine::ConfigureWif @backendPath={{this.id}} @displayName='AWS' @type='aws' @mountConfigModel={{this.mountConfigModel}} @issuerConfig={{this.issuerConfig}}/>
`);
assert.dom(SES.wif.accessType('wif')).isNotDisabled('WIF accessType is NOT disabled');
assert.dom(SES.wif.accessType('aws')).isNotDisabled('IAM accessType is NOT disabled');
});
test('it shows previously saved root and lease information', async function (assert) {
this.additionalConfigModel = createConfig(this.store, this.id, 'aws-lease');
await render(hbs`
<SecretEngine::ConfigureWif @backendPath={{this.id}} @displayName='AWS' @type='aws' @mountConfigModel={{this.mountConfigModel}} @issuerConfig={{this.issuerConfig}} @additionalConfigModel={{this.additionalConfigModel}}/>
`);
assert.dom(GENERAL.inputByAttr('accessKey')).hasValue(this.mountConfigModel.accessKey);
assert
.dom(GENERAL.inputByAttr('secretKey'))
.hasValue('**********', 'secretKey is masked on edit the value');
await click(GENERAL.toggleGroup('Root config options'));
assert.dom(GENERAL.inputByAttr('region')).hasValue(this.mountConfigModel.region);
assert.dom(GENERAL.inputByAttr('iamEndpoint')).hasValue(this.mountConfigModel.iamEndpoint);
assert.dom(GENERAL.inputByAttr('stsEndpoint')).hasValue(this.mountConfigModel.stsEndpoint);
assert.dom(GENERAL.inputByAttr('maxRetries')).hasValue('1');
// Check lease config values
assert.dom(GENERAL.ttl.input('Default Lease TTL')).hasValue('50');
assert.dom(GENERAL.ttl.input('Max Lease TTL')).hasValue('55');
});
test('it requires a double click to change the secret key', async function (assert) {
await render(hbs`
<SecretEngine::ConfigureWif @backendPath={{this.id}} @displayName='AWS' @type='aws' @mountConfigModel={{this.mountConfigModel}} @issuerConfig={{this.issuerConfig}} />
`);
this.server.post(configUrl('aws', this.id), (schema, req) => {
const payload = JSON.parse(req.requestBody);
assert.strictEqual(
payload.secret_key,
'new-secret',
'post request was made to config/root with the updated secret_key.'
);
});
await click(GENERAL.enableField('secretKey'));
await click('[data-test-button="toggle-masked"]');
await fillIn(GENERAL.inputByAttr('secretKey'), 'new-secret');
await click(GENERAL.saveButton);
});
});
});
module('isCommunity', function (hooks) {
hooks.beforeEach(function () {
this.version.type = 'community';
});
for (const type of WIF_ENGINES) {
test(`${type}:it does not show access type but defaults to type "account" fields`, async function (assert) {
this.id = `${type}-${this.uid}`;
this.mountConfigModel = createConfig(this.store, this.id, `${type}-generic`);
this.displayName = allEnginesArray.find((engine) => engine.type === type)?.displayName;
this.type = type;
await render(hbs`
<SecretEngine::ConfigureWif @backendPath={{this.id}} @displayName={{this.displayName}} @type={{this.type}} @mountConfigModel={{this.mountConfigModel}} @issuerConfig={{this.issuerConfig}} @additionalConfigModel={{this.additionalConfigModel}}/>
`);
assert.dom(SES.wif.accessTypeSection).doesNotExist('Access type section does not render');
// toggle grouped fields if it exists
const toggleGroup = document.querySelector('[data-test-toggle-group]');
toggleGroup ? await click(toggleGroup) : null;
for (const key of expectedConfigKeys(type, true)) {
if (key === 'secretKey' || key === 'clientSecret') return; // these keys are not returned by the API
assert
.dom(GENERAL.inputByAttr(key))
.hasValue(
this.mountConfigModel[key],
`${key} for ${type}: has the expected value set on the config`
);
}
});
}
});
});
});

View File

@@ -1,22 +0,0 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import Model from '@ember-data/model';
import type { ModelValidations } from 'vault/vault/app-types';
export default class AwsLeaseConfig extends Model {
backend: any;
leaseMax: any;
lease: any;
get attrs(): string[];
// for some reason the following Model attrs don't exist on the Model definition
changedAttributes(): {
[key: string]: unknown[];
};
isNew: boolean;
save(): void;
unloadRecord(): void;
validate(): ModelValidations;
}

View File

@@ -1,32 +0,0 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import type Model from '@ember-data/model';
export default class AwsRootConfig extends Model {
backend: any;
accessKey: any;
secretKey: any;
roleArn: any;
identityTokenAudience: any;
identityTokenTtl: any;
region: any;
iamEndpoint: any;
stsEndpoint: any;
maxRetries: any;
get attrs(): any;
get fieldGroupsWif(): any;
get fieldGroupsIam(): any;
formFieldGroups(accessType?: string): {
[key: string]: string[];
}[];
// for some reason the following Model attrs don't exist on the Model definition
changedAttributes(): {
[key: string]: unknown[];
};
isNew: boolean;
save(): void;
unloadRecord(): void;
}

View File

@@ -1,34 +0,0 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import type Model from '@ember-data/model';
import type { ModelValidations } from 'vault/app-types';
export default class AzureConfig extends Model {
backend: string;
subscriptionId: string | undefined;
tenantId: string | undefined;
clientId: string | undefined;
clientSecret: string | undefined;
identityTokenAudience: string | undefined;
identityTokenTtl: any;
environment: string | undefined;
rootPasswordTtl: string | undefined;
get displayAttrs(): any;
get isWifPluginConfigured(): boolean;
get isAzureAccountConfigured(): boolean;
get fieldGroupsWif(): any;
get fieldGroupsAzure(): any;
formFieldGroups(accessType?: string): {
[key: string]: string[];
}[];
changedAttributes(): {
[key: string]: unknown[];
};
isNew: boolean;
save(): void;
unloadRecord(): void;
}

View File

@@ -0,0 +1,28 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import type Model from '@ember-data/model';
import type { ModelValidations, FormField } from 'vault/app-types';
export default class SecretEngineAdditionalConfigModel extends Model {
backend: string;
type: string;
// aws lease
leaseMax: any;
lease: any;
get displayAttrs(): any;
formFields: FormField[];
changedAttributes(): {
[key: string]: unknown[];
};
isNew: boolean;
save(): void;
unloadRecord(): void;
destroyRecord(): void;
rollbackAttributes(): void;
validate(): ModelValidations;
}

View File

@@ -0,0 +1,53 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import type Model from '@ember-data/model';
import type { ModelValidations, FormFieldGroups } from 'vault/app-types';
export default class SecretEngineMountConfigModel extends Model {
backend: string;
type: string;
// aws
accessKey: any;
secretKey: any;
roleArn: any;
region: any;
iamEndpoint: any;
stsEndpoint: any;
maxRetries: any;
// azure
subscriptionId: string | undefined;
tenantId: string | undefined;
clientId: string | undefined;
clientSecret: string | undefined;
environment: string | undefined;
rootPasswordTtl: string | undefined;
// gcp
credentials: string | undefined;
ttl: any;
maxTtl: any;
secretAccountEmail: string | undefined;
// wif
identityTokenAudience: string | undefined;
identityTokenTtl: any;
get displayAttrs(): any;
get isConfigured(): boolean; // used only for secret engines that return a 200 when configuration has not be set.
get isWifPluginConfigured(): boolean;
get isAccountPluginConfigured(): boolean;
get fieldGroupsWif(): any;
get fieldGroupsAccount(): any;
formFieldGroups: FormFieldGroups[];
changedAttributes(): {
[key: string]: unknown[];
};
isNew: boolean;
save(): void;
unloadRecord(): void;
destroyRecord(): void;
rollbackAttributes(): void;
}