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 +~}} + +
+ + +
+ {{! 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. }} + {{#if this.version.isEnterprise}} +
+ Access Type +

+ {{#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). + {{/if}} +

+
+ + + + +
+
+ {{/if}} + {{#if (eq this.accessType "wif")}} + {{! WIF Fields }} + {{#each @issuerConfig.displayAttrs as |attr|}} + + {{/each}} + + {{else}} + {{! Azure Account Fields }} + + {{/if}} +
+ + + + + {{#if this.invalidFormAlert}} + + {{/if}} + + +{{#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[]; };