diff --git a/changelog/29047.txt b/changelog/29047.txt
new file mode 100644
index 000000000000..6afc9cdcd418
--- /dev/null
+++ b/changelog/29047.txt
@@ -0,0 +1,6 @@
+```release-note:improvement
+ui: Adds ability to edit, create, and view the Azure secrets engine configuration.
+```
+```release-note:improvement
+ui (enterprise): Allow WIF configuration on the Azure secrets engine.
+```
diff --git a/ui/app/adapters/azure/config.js b/ui/app/adapters/azure/config.js
index 26ba67ffae37..c848a8f741fa 100644
--- a/ui/app/adapters/azure/config.js
+++ b/ui/app/adapters/azure/config.js
@@ -23,4 +23,24 @@ export default class AzureConfig extends ApplicationAdapter {
};
});
}
+
+ createOrUpdate(store, type, snapshot) {
+ const serializer = store.serializerFor(type.modelName);
+ const data = serializer.serialize(snapshot);
+ const backend = snapshot.record.backend;
+ return this.ajax(this._url(backend), 'POST', { data }).then((resp) => {
+ return {
+ ...resp,
+ id: backend,
+ };
+ });
+ }
+
+ createRecord() {
+ return this.createOrUpdate(...arguments);
+ }
+
+ updateRecord() {
+ return this.createOrUpdate(...arguments);
+ }
}
diff --git a/ui/app/components/secret-engine/configuration-details.hbs b/ui/app/components/secret-engine/configuration-details.hbs
index b7c0f703e359..5a682b2a5655 100644
--- a/ui/app/components/secret-engine/configuration-details.hbs
+++ b/ui/app/components/secret-engine/configuration-details.hbs
@@ -30,15 +30,12 @@
@title="{{@typeDisplay}} not configured"
@message="Get started by configuring your {{@typeDisplay}} secrets engine."
>
- {{! TODO: short-term conditional to be removed once configuration for azure is merged. }}
- {{#unless (eq @typeDisplay "Azure")}}
-
- {{/unless}}
+
{{/each}}
\ No newline at end of file
diff --git a/ui/app/components/secret-engine/configure-azure.hbs b/ui/app/components/secret-engine/configure-azure.hbs
new file mode 100644
index 000000000000..64b98758f62c
--- /dev/null
+++ b/ui/app/components/secret-engine/configure-azure.hbs
@@ -0,0 +1,103 @@
+{{!
+ Copyright (c) HashiCorp, Inc.
+ SPDX-License-Identifier: BUSL-1.1
+~}}
+
+
+
+{{#if this.saveIssuerWarning}}
+
+
+ Are you sure?
+
+
+
+ {{this.saveIssuerWarning}}
+
+
+
+
+
+
+
+
+
+{{/if}}
\ No newline at end of file
diff --git a/ui/app/components/secret-engine/configure-azure.ts b/ui/app/components/secret-engine/configure-azure.ts
new file mode 100644
index 000000000000..45da5ccd073f
--- /dev/null
+++ b/ui/app/components/secret-engine/configure-azure.ts
@@ -0,0 +1,184 @@
+/**
+ * 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
+ *
+ *
+ * @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 {
+ @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 {
+ 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 {
+ 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();
+ }
+}
diff --git a/ui/app/helpers/mountable-secret-engines.js b/ui/app/helpers/mountable-secret-engines.js
index f5ac251d2c0f..7265ab3d9fd4 100644
--- a/ui/app/helpers/mountable-secret-engines.js
+++ b/ui/app/helpers/mountable-secret-engines.js
@@ -153,6 +153,10 @@ export function configurationOnly() {
// These engines do not exist in their own Ember engine.
export const CONFIGURABLE_SECRET_ENGINES = ['aws', 'azure', 'ssh'];
+export function configurableSecretEngines() {
+ return CONFIGURABLE_SECRET_ENGINES.slice();
+}
+
export function mountableEngines() {
return MOUNTABLE_SECRET_ENGINES.slice();
}
diff --git a/ui/app/models/azure/config.js b/ui/app/models/azure/config.js
index 208c2e11c9dc..a924ca658f44 100644
--- a/ui/app/models/azure/config.js
+++ b/ui/app/models/azure/config.js
@@ -4,7 +4,7 @@
*/
import Model, { attr } from '@ember-data/model';
-import { expandAttributeMeta } from 'vault/utils/field-to-attrs';
+import fieldToAttrs, { expandAttributeMeta } from 'vault/utils/field-to-attrs';
// Note: while the API docs indicate subscriptionId and tenantId are required, the UI does not enforce this because the user may pass these values in as environment variables.
// https://developer.hashicorp.com/vault/api-docs/secret/azure#configure-access
@@ -50,19 +50,59 @@ export default class AzureConfig extends Model {
'environment',
];
- // for configuration details view
- // do not include clientSecret because it is never returned by the API
- get displayAttrs() {
- return this.formFields.filter((attr) => attr.name !== 'clientSecret');
- }
-
+ /* GETTERS used by configure-azure component
+ these getters help:
+ 1. determine if the model is new or existing
+ 2. if wif or azure attributes have been configured
+ */
get isConfigured() {
// if every value is falsy, this engine has not been configured yet
return !this.configurableParams.every((param) => !this[param]);
}
- // formFields are iterated through to generate the edit/create view
- get formFields() {
- return expandAttributeMeta(this, this.configurableParams);
+ get isWifPluginConfigured() {
+ return !!this.identityTokenAudience || !!this.identityTokenTtl;
+ }
+
+ get isAzureAccountConfigured() {
+ // clientSecret is not checked here because it's never return by the API
+ // however it is an Azure account field
+ return !!this.rootPasswordTtl;
+ }
+
+ /* GETTERS used to generate array of fields to be displayed in:
+ 1. details view
+ 2. edit/create view
+*/
+ get displayAttrs() {
+ const formFields = expandAttributeMeta(this, this.configurableParams);
+ 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")
+ get fieldGroupsWif() {
+ return fieldToAttrs(this, this.formFieldGroups('wif'));
+ }
+
+ get fieldGroupsAzure() {
+ return fieldToAttrs(this, this.formFieldGroups('azure'));
+ }
+
+ formFieldGroups(accessType = 'azure') {
+ const formFieldGroups = [];
+ formFieldGroups.push({
+ default: ['subscriptionId', 'tenantId', 'clientId', 'environment'],
+ });
+ if (accessType === 'wif') {
+ formFieldGroups.push({
+ default: ['identityTokenAudience', 'identityTokenTtl'],
+ });
+ }
+ if (accessType === 'azure') {
+ formFieldGroups.push({
+ default: ['clientSecret', 'rootPasswordTtl'],
+ });
+ }
+ return formFieldGroups;
}
}
diff --git a/ui/app/routes/vault/cluster/secrets/backend/configuration/edit.ts b/ui/app/routes/vault/cluster/secrets/backend/configuration/edit.ts
index 68f9fe02e089..e56efa44e4d2 100644
--- a/ui/app/routes/vault/cluster/secrets/backend/configuration/edit.ts
+++ b/ui/app/routes/vault/cluster/secrets/backend/configuration/edit.ts
@@ -21,6 +21,7 @@ import type VersionService from 'vault/services/version';
const CONFIG_ADAPTERS_PATHS: Record = {
aws: ['aws/lease-config', 'aws/root-config'],
+ azure: ['azure/config'],
ssh: ['ssh/ca-config'],
};
@@ -47,10 +48,21 @@ export default class SecretsBackendConfigurationEdit extends Route {
// 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, '-');
try {
- model[standardizedKey] = await this.store.queryRecord(adapterPath, {
+ const configModel = await this.store.queryRecord(adapterPath, {
backend,
type,
});
+ // some of the models return a 200 if they are not configured (ex: azure)
+ // 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, {
+ backend,
+ type,
+ });
+ } else {
+ model[standardizedKey] = configModel;
+ }
} catch (e: AdapterError) {
// For most models if the adapter returns a 404, we want to create a new record.
// The ssh secret engine however returns a 400 if the CA is not configured.
diff --git a/ui/app/templates/vault/cluster/secrets/backend/configuration/edit.hbs b/ui/app/templates/vault/cluster/secrets/backend/configuration/edit.hbs
index 17fea24ef79b..80d0c30628d9 100644
--- a/ui/app/templates/vault/cluster/secrets/backend/configuration/edit.hbs
+++ b/ui/app/templates/vault/cluster/secrets/backend/configuration/edit.hbs
@@ -35,6 +35,12 @@
@issuerConfig={{this.model.identity-oidc-config}}
@backendPath={{this.model.id}}
/>
+{{else if (eq this.model.type "azure")}}
+
{{else if (eq this.model.type "ssh")}}
{{/if}}
\ No newline at end of file
diff --git a/ui/app/templates/vault/cluster/secrets/backend/configuration/index.hbs b/ui/app/templates/vault/cluster/secrets/backend/configuration/index.hbs
index dc7b40ed804f..abaef090a3e3 100644
--- a/ui/app/templates/vault/cluster/secrets/backend/configuration/index.hbs
+++ b/ui/app/templates/vault/cluster/secrets/backend/configuration/index.hbs
@@ -6,20 +6,17 @@
{{#if this.isConfigurable}}
- {{! TODO: short-term conditional to be removed once configuration for azure is merged. }}
- {{#unless (eq this.typeDisplay "Azure")}}
-
-
-
- Configure
-
-
-
- {{/unless}}
+
+
+
+ Configure
+
+
+
{
+ assert.ok(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);
+ for (const key of expectedConfigKeys('azure')) {
+ assert.dom(GENERAL.infoRowLabel(key)).exists(`${key} on the ${this.type} config details exists.`);
+ const responseKeyAndValue = expectedValueOfConfigKeys(this.type, key);
+ assert
+ .dom(GENERAL.infoRowValue(key))
+ .hasText(responseKeyAndValue, `value for ${key} on the ${this.type} config details exists.`);
+ }
+ // check mount configuration details are present and accurate.
+ await click(SES.configurationToggle);
+ assert
+ .dom(GENERAL.infoRowValue('Path'))
+ .hasText(`${path}/`, 'mount path is displayed in the configuration details');
+ // 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');
+ });
});
- test('it should not show "Configure" from toolbar', async function (assert) {
- const path = `azure-${this.uid}`;
- await enablePage.enable('azure', path);
- assert.dom(SES.configure).doesNotExist('Configure button does not exist.');
- // cleanup
- await runCmd(`delete sys/mounts/${path}`);
+ module('create', function () {
+ test('it should save azure account accessType options', async function (assert) {
+ assert.expect(3);
+ const path = `azure-${this.uid}`;
+ 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.'
+ );
+ });
+
+ await click(SES.configTab);
+ await click(SES.configure);
+ await fillInAzureConfig(this.type);
+ await click(GENERAL.saveButton);
+ assert.true(
+ this.flashSuccessSpy.calledWith(`Successfully saved ${path}'s configuration.`),
+ 'Success flash message is rendered showing the azure model configuration was saved.'
+ );
+ assert
+ .dom(GENERAL.infoRowValue('Root password TTL'))
+ .hasText(
+ '1 hour 26 minutes 40 seconds',
+ 'Root password TTL, an azure account specific field, has been set.'
+ );
+ assert
+ .dom(GENERAL.infoRowValue('Subscription ID'))
+ .hasText('subscription-id', 'Subscription ID, a generic field, has been set.');
+ // cleanup
+ await runCmd(`delete sys/mounts/${path}`);
+ });
});
- test('it should show configuration with WIF options configured', async function (assert) {
- const path = `azure-${this.uid}`;
- const type = 'azure';
- const wifAttrs = {
- subscription_id: 'subscription-id',
- tenant_id: 'tenant-id',
- client_id: 'client-id',
- identity_token_audience: 'audience',
- identity_token_ttl: 720000,
- environment: 'AZUREPUBLICCLOUD',
- };
- this.server.get(`${path}/config`, () => {
- assert.ok(true, 'request made to config when navigating to the configuration page.');
- return { data: { id: path, type, ...wifAttrs } };
+ module('edit', function (hooks) {
+ hooks.beforeEach(async function () {
+ const path = `azure-${this.uid}`;
+ const type = 'azure';
+ const genericAttrs = {
+ // attributes that can be used for either wif or azure account access type
+ subscription_id: 'subscription-id',
+ tenant_id: 'tenant-id',
+ client_id: 'client-id',
+ environment: 'AZUREPUBLICCLOUD',
+ };
+ this.server.get(`${path}/config`, () => {
+ return { data: { id: path, type, ...genericAttrs } };
+ });
+ await enablePage.enable(type, path);
+ });
+
+ test('it should not save client secret if it has NOT been changed', async function (assert) {
+ assert.expect(2);
+ await click(SES.configure);
+ const url = currentURL();
+ const path = url.split('/')[3]; // get path from url because we can't pass the path from beforeEach hook to individual test.
+ this.server.post(configUrl('azure', path), (schema, req) => {
+ const payload = JSON.parse(req.requestBody);
+ assert.strictEqual(
+ undefined,
+ payload.client_secret,
+ 'client_secret is not sent if it has not been changed'
+ );
+ assert.strictEqual(
+ payload.subscription_id,
+ 'subscription-id-updated',
+ 'subscription_id is included with updated value in the payload'
+ );
+ });
+ await fillIn(GENERAL.inputByAttr('subscriptionId'), 'subscription-id-updated');
+ await click(GENERAL.enableField('clientSecret'));
+ await click(GENERAL.saveButton);
+ // cleanup
+ await runCmd(`delete sys/mounts/${path}`);
+ });
+
+ test('it should save client secret if it HAS been changed', async function (assert) {
+ assert.expect(2);
+ await click(SES.configure);
+ const url = currentURL();
+ const path = url.split('/')[3]; // get path from url because we can't pass the path from beforeEach hook to individual test.
+ this.server.post(configUrl('azure', path), (schema, req) => {
+ const payload = JSON.parse(req.requestBody);
+ assert.strictEqual(
+ payload.client_secret,
+ 'client-secret-updated',
+ 'client_secret is sent on payload because user updated its value'
+ );
+ assert.strictEqual(
+ payload.subscription_id,
+ 'subscription-id-updated-again',
+ 'subscription_id is included with updated value in the payload'
+ );
+ });
+ await fillIn(GENERAL.inputByAttr('subscriptionId'), 'subscription-id-updated-again');
+ await click(GENERAL.enableField('clientSecret'));
+ await click('[data-test-button="toggle-masked"]');
+ await fillIn(GENERAL.inputByAttr('clientSecret'), 'client-secret-updated');
+ await click(GENERAL.saveButton);
+ // cleanup
+ await runCmd(`delete sys/mounts/${path}`);
});
- await enablePage.enable(type, path);
- for (const key of expectedConfigKeys('azure-wif')) {
- const responseKeyAndValue = expectedValueOfConfigKeys(type, key);
+ });
+ });
+
+ module('isEnterprise', function (hooks) {
+ hooks.beforeEach(function () {
+ this.version.type = 'enterprise';
+ });
+
+ module('details', function () {
+ test('it should save WIF configuration options', async function (assert) {
+ const path = `azure-${this.uid}`;
+ const wifAttrs = {
+ subscription_id: 'subscription-id',
+ tenant_id: 'tenant-id',
+ client_id: 'client-id',
+ identity_token_audience: 'audience',
+ identity_token_ttl: 720000,
+ environment: 'AZUREPUBLICCLOUD',
+ };
+ this.server.get(`${path}/config`, () => {
+ assert.ok(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);
+ for (const key of expectedConfigKeys('azure-wif')) {
+ const responseKeyAndValue = expectedValueOfConfigKeys(this.type, key);
+ assert
+ .dom(GENERAL.infoRowValue(key))
+ .hasText(responseKeyAndValue, `value for ${key} on the ${this.type} config details exists.`);
+ }
+ // check mount configuration details are present and accurate.
+ await click(SES.configurationToggle);
assert
- .dom(GENERAL.infoRowValue(key))
- .hasText(responseKeyAndValue, `value for ${key} on the ${type} config details exists.`);
- }
- // check mount configuration details are present and accurate.
- await click(SES.configurationToggle);
- assert
- .dom(GENERAL.infoRowValue('Path'))
- .hasText(`${path}/`, 'mount path is displayed in the configuration details');
- // cleanup
- await runCmd(`delete sys/mounts/${path}`);
+ .dom(GENERAL.infoRowValue('Path'))
+ .hasText(`${path}/`, 'mount path is displayed in the configuration details');
+ // cleanup
+ await runCmd(`delete sys/mounts/${path}`);
+ });
+
+ test('it should not show issuer if no WIF configuration data is returned', async function (assert) {
+ 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.');
+ 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.');
+ });
+ await createConfig(this.store, path, this.type); // create the azure account config in the store
+ await enablePage.enable(this.type, path);
+ await click(SES.configTab);
+
+ assert.dom(GENERAL.infoRowLabel('Issuer')).doesNotExist(`Issuer does not exists on config details.`);
+ // cleanup
+ await runCmd(`delete sys/mounts/${path}`);
+ });
});
- test('it should show configuration with Azure account options configured', async function (assert) {
- const path = `azure-${this.uid}`;
- const type = 'azure';
- const azureAccountAttrs = {
- client_secret: 'client-secret',
- subscription_id: 'subscription-id',
- tenant_id: 'tenant-id',
- client_id: 'client-id',
- root_password_ttl: '20 days 20 hours',
- environment: 'AZUREPUBLICCLOUD',
- };
- this.server.get(`${path}/config`, () => {
- assert.ok(true, 'request made to config when navigating to the configuration page.');
- return { data: { id: path, type, ...azureAccountAttrs } };
+ module('create', function () {
+ test('it should transition and save issuer if model was not changed but issuer was', async function (assert) {
+ assert.expect(3);
+ const path = `azure-${this.uid}`;
+ await enablePage.enable(this.type, path);
+ const newIssuer = `http://new.issuer.${this.uid}`;
+
+ 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',
+ 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(this.type, path), () => {
+ throw new Error('post request was incorrectly made to update the azure config model');
+ });
+
+ await click(SES.configTab);
+ await click(SES.configure);
+ await click(SES.wif.accessType('wif'));
+ await fillIn(GENERAL.inputByAttr('issuer'), newIssuer);
+ await click(GENERAL.saveButton);
+ await click(SES.wif.issuerWarningSave);
+ assert.true(
+ this.flashSuccessSpy.calledWith(`Issuer saved successfully`),
+ 'Shows issuer saved message'
+ );
+
+ assert
+ .dom(GENERAL.emptyStateTitle)
+ .hasText(
+ 'Azure not configured',
+ 'Empty state message is displayed because the model was not saved only the issuer'
+ );
+ // cleanup
+ await runCmd(`delete sys/mounts/${path}`);
+ });
+
+ test('it should transition and save model if the model was changed but issuer was not', async function (assert) {
+ assert.expect(4);
+ const path = `azure-${this.uid}`;
+ await enablePage.enable(this.type, path);
+
+ this.server.post('/identity/oidc/config', () => {
+ throw new Error('post request was incorrectly made to update the issuer');
+ });
+
+ await click(SES.configTab);
+ await click(SES.configure);
+ await fillInAzureConfig('withWif');
+ await click(GENERAL.saveButton);
+ assert.dom(SES.wif.issuerWarningModal).doesNotExist('issuer warning modal does not show');
+ assert.true(
+ this.flashSuccessSpy.calledWith(`Successfully saved ${path}'s configuration.`),
+ 'Success flash message is rendered showing the azure model configuration was saved.'
+ );
+ assert
+ .dom(GENERAL.infoRowValue('Identity token audience'))
+ .hasText('azure-audience', 'Identity token audience has been set.');
+ assert
+ .dom(GENERAL.infoRowValue('Identity token TTL'))
+ .hasText('2 hours', 'Identity token TTL has been set.');
+ // cleanup
+ await runCmd(`delete sys/mounts/${path}`);
+ });
+
+ test('it should transition and show issuer error if model saved but issuer encountered an error', async function (assert) {
+ const path = `azure-${this.uid}`;
+ const oldIssuer = 'http://old.issuer';
+ await enablePage.enable(this.type, path);
+ this.server.get('/identity/oidc/config', () => {
+ return { issuer: 'http://old.issuer' };
+ });
+ this.server.post('/identity/oidc/config', () => {
+ return overrideResponse(400, { errors: ['bad request'] });
+ });
+
+ await click(SES.configTab);
+ await click(SES.configure);
+ await fillInAzureConfig('withWif');
+ assert
+ .dom(GENERAL.inputByAttr('issuer'))
+ .hasValue(oldIssuer, 'issuer defaults to previously saved value');
+ await fillIn(GENERAL.inputByAttr('issuer'), 'http://new.issuererrors');
+ await click(GENERAL.saveButton);
+ await click(SES.wif.issuerWarningSave);
+ assert.true(
+ this.flashSuccessSpy.calledWith(`Successfully saved ${path}'s configuration.`),
+ 'Success flash message is rendered showing the azure model configuration was saved.'
+ );
+ assert.true(
+ this.flashDangerSpy.calledWith(`Issuer was not saved: bad request`),
+ 'Danger flash message is rendered showing the issuer was not saved.'
+ );
+ assert
+ .dom(GENERAL.infoRowValue('Identity token audience'))
+ .hasText('azure-audience', 'Identity token audience has been set.');
+ assert
+ .dom(GENERAL.infoRowValue('Identity token TTL'))
+ .hasText('2 hours', 'Identity token TTL has been set.');
+ assert
+ .dom(GENERAL.infoRowValue('Issuer'))
+ .hasText(oldIssuer, 'Issuer is shows the old saved value not the new value that errors on save.');
+ // cleanup
+ await runCmd(`delete sys/mounts/${path}`);
});
- await enablePage.enable(type, path);
- for (const key of expectedConfigKeys('azure')) {
- assert.dom(GENERAL.infoRowLabel(key)).exists(`${key} on the ${type} config details exists.`);
- const responseKeyAndValue = expectedValueOfConfigKeys(type, key);
+
+ test('it should NOT transition and show error if model errored but issuer was saved', async function (assert) {
+ const path = `azure-${this.uid}`;
+ const newIssuer = `http://new.issuer.${this.uid}`;
+ const oldIssuer = 'http://old.issuer';
+ await enablePage.enable(this.type, path);
+ this.server.get('/identity/oidc/config', () => {
+ return { issuer: oldIssuer };
+ });
+ this.server.post(configUrl(this.type, path), () => {
+ return overrideResponse(400, { errors: ['bad request'] });
+ });
+
+ await click(SES.configTab);
+ await click(SES.configure);
+ await fillInAzureConfig('withWif');
+ assert
+ .dom(GENERAL.inputByAttr('issuer'))
+ .hasValue(oldIssuer, 'issuer defaults to previously saved value');
+ await fillIn(GENERAL.inputByAttr('issuer'), newIssuer);
+ await click(GENERAL.saveButton);
+ await click(SES.wif.issuerWarningSave);
+ assert.true(
+ this.flashSuccessSpy.calledWith(`Issuer saved successfully`),
+ 'Success flash message is rendered showing the issuer configuration was saved.'
+ );
+ assert.dom(GENERAL.messageError).hasText('Error bad request', 'Error message is displayed.');
+ assert.strictEqual(
+ currentURL(),
+ `/vault/secrets/${path}/configuration/edit`,
+ 'stays on the edit page'
+ );
assert
- .dom(GENERAL.infoRowValue(key))
- .hasText(responseKeyAndValue, `value for ${key} on the ${type} config details exists.`);
- }
- // check mount configuration details are present and accurate.
- await click(SES.configurationToggle);
- assert
- .dom(GENERAL.infoRowValue('Path'))
- .hasText(`${path}/`, 'mount path is displayed in the configuration details');
- // cleanup
- await runCmd(`delete sys/mounts/${path}`);
+ .dom(GENERAL.inputByAttr('issuer'))
+ .hasValue(newIssuer, 'issuer is updated to newly saved value');
+ // 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}`;
- const type = 'azure';
- // interrupt get and return API error
- this.server.get(configUrl(type, path), () => {
- return overrideResponse(400, { errors: ['bad request'] });
+ module('edit', function () {
+ test('it should update WIF attributes', async function (assert) {
+ const path = `azure-${this.uid}`;
+ await enablePage.enable(this.type, path);
+ await click(SES.configTab);
+ await click(SES.configure);
+ await fillInAzureConfig('withWif');
+ await click(GENERAL.saveButton); // finished creating attributes, go back and edit them.
+ assert
+ .dom(GENERAL.infoRowValue('Identity token audience'))
+ .hasText('azure-audience', `value for identity token audience shows on the config details view.`);
+ await click(SES.configure);
+ await fillIn(GENERAL.inputByAttr('identityTokenAudience'), 'new-audience');
+ await click(GENERAL.saveButton);
+ assert
+ .dom(GENERAL.infoRowValue('Identity token audience'))
+ .hasText('new-audience', `value for identity token audience shows on the config details view.`);
+ // cleanup
+ await runCmd(`delete sys/mounts/${path}`);
});
- await enablePage.enable(type, path);
- assert.dom(SES.error.title).hasText('Error', 'shows the secrets backend error route');
});
});
});
diff --git a/ui/tests/helpers/secret-engine/secret-engine-helpers.js b/ui/tests/helpers/secret-engine/secret-engine-helpers.js
index 4d5723f64b15..f21dd0a9eb5b 100644
--- a/ui/tests/helpers/secret-engine/secret-engine-helpers.js
+++ b/ui/tests/helpers/secret-engine/secret-engine-helpers.js
@@ -99,7 +99,7 @@ const createSshCaConfig = (store, backend) => {
return store.peekRecord('ssh/ca-config', backend);
};
-const createAzureConfig = (store, backend, accessType) => {
+const createAzureConfig = (store, backend, accessType = 'generic') => {
// clear any records first
// note: allowed "environment" params for testing https://github.com/hashicorp/vault-plugin-secrets-azure/blob/main/client.go#L35-L37
store.unloadAll('azure/config');
@@ -117,6 +117,21 @@ const createAzureConfig = (store, backend, accessType) => {
environment: 'AZUREPUBLICCLOUD',
},
});
+ } else if (accessType === 'wif') {
+ store.pushPayload('azure/config', {
+ id: backend,
+ modelName: 'azure/config',
+ data: {
+ backend,
+ subscription_id: 'subscription-id',
+ tenant_id: 'tenant-id',
+ client_id: 'client-id',
+ identity_token_audience: 'audience',
+ identity_token_ttl: 7200,
+ root_password_ttl: '20 days 20 hours',
+ environment: 'AZUREPUBLICCLOUD',
+ },
+ });
} else {
store.pushPayload('azure/config', {
id: backend,
@@ -162,6 +177,10 @@ export const createConfig = (store, backend, type) => {
return createSshCaConfig(store, backend);
case 'azure':
return createAzureConfig(store, backend, 'azure');
+ case 'azure-wif':
+ return createAzureConfig(store, backend, 'wif');
+ case 'azure-generic':
+ return createAzureConfig(store, backend, 'generic');
}
};
// Used in tests to assert the expected keys in the config details of configurable secret engines
@@ -286,6 +305,25 @@ export const fillInAwsConfig = async (situation = 'withAccess') => {
}
};
+export const fillInAzureConfig = async (situation = 'azure') => {
+ await fillIn(GENERAL.inputByAttr('subscriptionId'), 'subscription-id');
+ await fillIn(GENERAL.inputByAttr('tenantId'), 'tenant-id');
+ await fillIn(GENERAL.inputByAttr('clientId'), 'client-id');
+ await fillIn(GENERAL.inputByAttr('environment'), 'AZUREPUBLICCLOUD');
+
+ if (situation === 'azure') {
+ await fillIn(GENERAL.inputByAttr('clientSecret'), 'client-secret');
+ await click(GENERAL.ttl.toggle('Root password TTL'));
+ await fillIn(GENERAL.ttl.input('Root password TTL'), '5200');
+ }
+ if (situation === 'withWif') {
+ await click(SES.wif.accessType('wif')); // toggle to wif
+ await fillIn(GENERAL.inputByAttr('identityTokenAudience'), 'azure-audience');
+ await click(GENERAL.ttl.toggle('Identity token TTL'));
+ await fillIn(GENERAL.ttl.input('Identity token TTL'), '7200');
+ }
+};
+
// Example usage
// createLongJson (2, 3) will create a json object with 2 original keys, each with 3 nested keys
// {
diff --git a/ui/tests/integration/components/secret-engine/configure-azure-test.js b/ui/tests/integration/components/secret-engine/configure-azure-test.js
new file mode 100644
index 000000000000..996f1bbdfe23
--- /dev/null
+++ b/ui/tests/integration/components/secret-engine/configure-azure-test.js
@@ -0,0 +1,426 @@
+/**
+ * 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`
+
+ `);
+ };
+ });
+ 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-camelCase')) {
+ 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-camelCase')) {
+ 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(8);
+ 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-camelCase')) {
+ 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);
+ });
+ });
+ });
+});
diff --git a/ui/types/vault/models/azure/config.d.ts b/ui/types/vault/models/azure/config.d.ts
index ff5075b5af87..70decffc3440 100644
--- a/ui/types/vault/models/azure/config.d.ts
+++ b/ui/types/vault/models/azure/config.d.ts
@@ -17,7 +17,14 @@ export default class AzureConfig extends Model {
environment: string | undefined;
rootPasswordTtl: string | undefined;
- get attrs(): any;
+ 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[];
};