Allow Configuration of Azure Secret Engine, including WIF for enterprise users (#29047)

* transfer over all changes from original pr

* changelog

* add serialize catch for no empty string environment

* move ttl format logic to parent route

* Update 29047.txt

* clean up some comments

* Update changelog/29047.txt

Co-authored-by: claire bontempo <68122737+hellobontempo@users.noreply.github.com>

* Update changelog/29047.txt

Co-authored-by: claire bontempo <68122737+hellobontempo@users.noreply.github.com>

* Update ui/app/components/secret-engine/configure-azure.hbs

Co-authored-by: claire bontempo <68122737+hellobontempo@users.noreply.github.com>

* first round of addressing pr comments, holding off on the issue save flow for error messaging to keep separate

* Update CODEOWNERS

merge issue

* small clean up tasks

* updates

* test coverage

* small cleanup

* small clean up

* clean up

* clean up getters on model

---------

Co-authored-by: claire bontempo <68122737+hellobontempo@users.noreply.github.com>
This commit is contained in:
Angel Garbarino
2024-12-18 16:28:07 -07:00
committed by GitHub
parent fc89097c38
commit 2631ae67d4
14 changed files with 1284 additions and 128 deletions

6
changelog/29047.txt Normal file
View File

@@ -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.
```

View File

@@ -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);
}
}

View File

@@ -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")}}
<Hds::Link::Standalone
@icon="chevron-right"
@iconPosition="trailing"
@text="Configure {{@typeDisplay}}"
@route="vault.cluster.secrets.backend.configuration.edit"
@model={{@id}}
/>
{{/unless}}
<Hds::Link::Standalone
@icon="chevron-right"
@iconPosition="trailing"
@text="Configure {{@typeDisplay}}"
@route="vault.cluster.secrets.backend.configuration.edit"
@model={{@id}}
/>
</EmptyState>
{{/each}}

View File

@@ -0,0 +1,103 @@
{{!
Copyright (c) HashiCorp, Inc.
SPDX-License-Identifier: BUSL-1.1
~}}
<form {{on "submit" (perform this.submitForm)}} aria-label="configure azure credentials" data-test-configure-form>
<NamespaceReminder @mode="save" @noun="configuration" />
<MessageError @errorMessage={{this.errorMessage}} />
<div class="box is-fullwidth is-sideless">
{{! accessType can be "azure" or "wif" - since WIF is an enterprise only feature we default to "azure" for community users and only display those related form fields. }}
{{#if this.version.isEnterprise}}
<fieldset class="field form-fieldset" id="protection" data-test-access-type-section>
<legend class="is-label">Access Type</legend>
<p class="sub-text" data-test-access-type-subtext>
{{#if this.disableAccessType}}
You cannot edit Access Type if you have already saved access credentials.
{{else}}
Choose the way to configure access to Azure. Access can be configured either using an Azure account or with the
Plugin Workload Identity Federation (WIF).
{{/if}}
</p>
<div>
<RadioButton
id="access-type-azure"
name="azure"
class="radio"
data-test-access-type="azure"
@value="azure"
@groupValue={{this.accessType}}
@onChange={{fn this.onChangeAccessType "azure"}}
@disabled={{this.disableAccessType}}
/>
<label for="access-type-azure">Azure account credentials</label>
<RadioButton
id="access-type-wif"
name="wif"
class="radio has-left-margin-m"
data-test-access-type="wif"
@value="wif"
@groupValue={{this.accessType}}
@onChange={{fn this.onChangeAccessType "wif"}}
@disabled={{this.disableAccessType}}
/>
<label for="access-type-wif">Workload Identity Federation</label>
</div>
</fieldset>
{{/if}}
{{#if (eq this.accessType "wif")}}
{{! WIF Fields }}
{{#each @issuerConfig.displayAttrs as |attr|}}
<FormField @attr={{attr}} @model={{@issuerConfig}} />
{{/each}}
<FormFieldGroups @model={{@model}} @mode={{if @model.isNew "create" "edit"}} @groupName="fieldGroupsWif" />
{{else}}
{{! Azure Account Fields }}
<FormFieldGroups
@model={{@model}}
@mode={{if @model.isNew "create" "edit"}}
@useEnableInput={{true}}
@groupName="fieldGroupsAzure"
/>
{{/if}}
</div>
<Hds::ButtonSet>
<Hds::Button
@text="Save"
@icon={{if this.save.isRunning "loading"}}
type="submit"
disabled={{this.save.isRunning}}
data-test-save
/>
<Hds::Button
@text="Cancel"
@color="secondary"
class="has-left-margin-s"
disabled={{this.save.isRunning}}
{{on "click" this.onCancel}}
data-test-cancel
/>
</Hds::ButtonSet>
{{#if this.invalidFormAlert}}
<AlertInline data-test-invalid-form-alert class="has-top-padding-s" @type="danger" @message={{this.invalidFormAlert}} />
{{/if}}
</form>
{{#if this.saveIssuerWarning}}
<Hds::Modal @color="warning" @onClose={{action (mut this.saveIssuerWarning) ""}} data-test-issuer-warning as |M|>
<M.Header @icon="alert-circle">
Are you sure?
</M.Header>
<M.Body>
<p class="has-bottom-margin-s" data-test-issuer-warning-message>
{{this.saveIssuerWarning}}
</p>
</M.Body>
<M.Footer as |F|>
<Hds::ButtonSet>
<Hds::Button @text="Continue" {{on "click" this.continueSubmitForm}} data-test-issuer-save />
<Hds::Button @text="Cancel" @color="secondary" {{on "click" F.close}} data-test-issuer-cancel />
</Hds::ButtonSet>
</M.Footer>
</Hds::Modal>
{{/if}}

View File

@@ -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
* <SecretEngine::ConfigureAzure
@model={{this.model.azure-config}}
@backendPath={{this.model.id}}
@issuerConfig={{this.model.identity-oidc-config}}
/>
*
* @param {object} model - Azure config model
* @param {string} backendPath - name of the Azure secret engine, ex: 'azure-123'
* @param {object} issuerConfigModel - the identity/oidc/config model
*/
interface Args {
model: ConfigModel;
issuerConfig: IdentityOidcConfigModel;
backendPath: string;
}
export default class ConfigureAzureComponent extends Component<Args> {
@service declare readonly router: Router;
@service declare readonly store: StoreService;
@service declare readonly version: VersionService;
@service declare readonly flashMessages: FlashMessageService;
@tracked accessType = 'azure';
@tracked errorMessage = '';
@tracked invalidFormAlert = '';
@tracked saveIssuerWarning = '';
disableAccessType = false;
constructor(owner: unknown, args: Args) {
super(owner, args);
// the following checks are only relevant to existing enterprise configurations
if (this.version.isCommunity && this.args.model.isNew) return;
const { isWifPluginConfigured, isAzureAccountConfigured } = this.args.model;
this.accessType = isWifPluginConfigured ? 'wif' : 'azure';
// if there are either WIF or azure attributes, disable user's ability to change accessType
this.disableAccessType = isWifPluginConfigured || isAzureAccountConfigured;
}
get modelAttrChanged() {
// "backend" dirties model state so explicity ignore it here
return Object.keys(this.args.model?.changedAttributes()).some((item) => item !== 'backend');
}
get issuerAttrChanged() {
return this.args.issuerConfig?.hasDirtyAttributes;
}
@action continueSubmitForm() {
this.saveIssuerWarning = '';
this.save.perform();
}
// check if the issuer has been changed to show issuer modal
// continue saving the configuration
submitForm = task(
waitFor(async (event: Event) => {
event?.preventDefault();
this.resetErrors();
if (this.issuerAttrChanged) {
// if the issuer has changed show modal with warning that the config will change
// if the modal is shown, the user has to click confirm to continue saving
this.saveIssuerWarning = `You are updating the global issuer config. This will overwrite Vault's current issuer ${
this.args.issuerConfig.queryIssuerError ? 'if it exists ' : ''
}and may affect other configurations using this value. Continue?`;
// exit task until user confirms
return;
}
await this.save.perform();
})
);
save = task(
waitFor(async () => {
const modelAttrChanged = this.modelAttrChanged;
const issuerAttrChanged = this.issuerAttrChanged;
// check if any of the model or issue attributes have changed
// if no changes, transition and notify user
if (!modelAttrChanged && !issuerAttrChanged) {
this.flashMessages.info('No changes detected.');
this.transition();
return;
}
const modelSaved = modelAttrChanged ? await this.saveModel() : false;
const issuerSaved = issuerAttrChanged ? await this.updateIssuer() : false;
if (modelSaved || (!modelAttrChanged && issuerSaved)) {
// transition if the model was saved successfully
// we only prevent a transition if the model is edited and fails saving
this.transition();
} else {
// otherwise there was a failure and we should not transition and exit the function
return;
}
})
);
async updateIssuer(): Promise<boolean> {
try {
await this.args.issuerConfig.save();
this.flashMessages.success('Issuer saved successfully');
return true;
} catch (e) {
this.flashMessages.danger(`Issuer was not saved: ${errorMessage(e, 'Check Vault logs for details.')}`);
// remove issuer from the config model if it was not saved
this.args.issuerConfig.rollbackAttributes();
return false;
}
}
async saveModel(): Promise<boolean> {
const { backendPath, model } = this.args;
try {
await model.save();
this.flashMessages.success(`Successfully saved ${backendPath}'s configuration.`);
return true;
} catch (error) {
this.errorMessage = errorMessage(error);
this.invalidFormAlert = 'There was an error submitting this form.';
return false;
}
}
resetErrors() {
this.flashMessages.clearMessages();
this.errorMessage = '';
this.invalidFormAlert = '';
}
transition() {
this.router.transitionTo('vault.cluster.secrets.backend.configuration', this.args.backendPath);
}
@action
onChangeAccessType(accessType: string) {
this.accessType = accessType;
const { model } = this.args;
if (accessType === 'azure') {
// reset all WIF attributes
model.identityTokenAudience = model.identityTokenTtl = undefined;
// return the issuer to the globally set value (if there is one) on toggle
this.args.issuerConfig.rollbackAttributes();
}
if (accessType === 'wif') {
// reset all Azure attributes
model.clientSecret = model.rootPasswordTtl = undefined;
}
}
@action
onCancel() {
this.resetErrors();
this.args.model.unloadRecord();
this.transition();
}
}

View File

@@ -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();
}

View File

@@ -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;
}
}

View File

@@ -21,6 +21,7 @@ import type VersionService from 'vault/services/version';
const CONFIG_ADAPTERS_PATHS: Record<string, string[]> = {
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.

View File

@@ -35,6 +35,12 @@
@issuerConfig={{this.model.identity-oidc-config}}
@backendPath={{this.model.id}}
/>
{{else if (eq this.model.type "azure")}}
<SecretEngine::ConfigureAzure
@model={{this.model.azure-config}}
@backendPath={{this.model.id}}
@issuerConfig={{this.model.identity-oidc-config}}
/>
{{else if (eq this.model.type "ssh")}}
<SecretEngine::ConfigureSsh @model={{this.model.ssh-ca-config}} @id={{this.model.id}} />
{{/if}}

View File

@@ -6,20 +6,17 @@
<SecretListHeader @model={{this.model.secretEngineModel}} @isConfigure={{true}} />
{{#if this.isConfigurable}}
{{! TODO: short-term conditional to be removed once configuration for azure is merged. }}
{{#unless (eq this.typeDisplay "Azure")}}
<Toolbar>
<ToolbarActions>
<ToolbarLink
@route="vault.cluster.secrets.backend.configuration.edit"
@model={{this.model.secretEngineModel.id}}
data-test-secret-backend-configure
>
Configure
</ToolbarLink>
</ToolbarActions>
</Toolbar>
{{/unless}}
<Toolbar>
<ToolbarActions>
<ToolbarLink
@route="vault.cluster.secrets.backend.configuration.edit"
@model={{this.model.secretEngineModel.id}}
data-test-secret-backend-configure
>
Configure
</ToolbarLink>
</ToolbarActions>
</Toolbar>
<SecretEngine::ConfigurationDetails
@configModels={{this.model.configModels}}

View File

@@ -3,7 +3,7 @@
* SPDX-License-Identifier: BUSL-1.1
*/
import { click, visit, currentURL } from '@ember/test-helpers';
import { click, visit, currentURL, fillIn } from '@ember/test-helpers';
import { module, test } from 'qunit';
import { setupApplicationTest } from 'ember-qunit';
import { v4 as uuidv4 } from 'uuid';
@@ -21,6 +21,8 @@ import {
expectedConfigKeys,
expectedValueOfConfigKeys,
configUrl,
createConfig,
fillInAzureConfig,
} from 'vault/tests/helpers/secret-engine/secret-engine-helpers';
module('Acceptance | Azure | configuration', function (hooks) {
@@ -31,113 +33,427 @@ module('Acceptance | Azure | configuration', function (hooks) {
const flash = this.owner.lookup('service:flash-messages');
this.store = this.owner.lookup('service:store');
this.flashSuccessSpy = spy(flash, 'success');
this.flashDangerSpy = spy(flash, 'danger');
this.flashInfoSpy = spy(flash, 'info');
this.version = this.owner.lookup('service:version');
this.uid = uuidv4();
this.type = 'azure';
return authPage.login();
});
test('it should prompt configuration after mounting the azure engine', async function (assert) {
const path = `azure-${this.uid}`;
await visit('/vault/settings/mount-secret-backend');
await mountBackend(this.type, path);
assert.strictEqual(
currentURL(),
`/vault/secrets/${path}/configuration`,
'navigated to configuration view'
);
assert
.dom(GENERAL.emptyStateTitle)
.hasText('Azure not configured', "empty state title is 'Azure not configured'");
assert.dom(GENERAL.emptyStateActions).hasText('Configure Azure');
// cleanup
await runCmd(`delete sys/mounts/${path}`);
});
test('it should transition to configure page on click "Configure" from toolbar', async function (assert) {
const path = `azure-${this.uid}`;
await enablePage.enable(this.type, path);
await click(SES.configure);
assert.strictEqual(
currentURL(),
`/vault/secrets/${path}/configuration/edit`,
'navigated to configuration edit view'
);
// cleanup
await runCmd(`delete sys/mounts/${path}`);
});
module('isCommunity', function (hooks) {
hooks.beforeEach(function () {
this.version.type = 'community';
});
module('details', function () {
test('it should show configuration with Azure account options configured', async function (assert) {
const path = `azure-${this.uid}`;
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: 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');
});
});
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}`);
});
});
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}`);
});
});
});
module('isEnterprise', function (hooks) {
hooks.beforeEach(function () {
this.version.type = 'enterprise';
});
test('it should show empty state and navigate to configuration view after mounting the azure engine', async function (assert) {
const path = `azure-${this.uid}`;
await visit('/vault/settings/mount-secret-backend');
await mountBackend('azure', path);
assert.strictEqual(
currentURL(),
`/vault/secrets/${path}/configuration`,
'navigated to configuration view'
);
assert.dom(GENERAL.emptyStateTitle).hasText('Azure not configured');
assert.dom(GENERAL.emptyStateActions).doesNotContainText('Configure Azure');
// cleanup
await runCmd(`delete sys/mounts/${path}`);
});
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}`);
});
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 } };
});
await enablePage.enable(type, path);
for (const key of expectedConfigKeys('azure-wif')) {
const responseKeyAndValue = expectedValueOfConfigKeys(type, key);
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 } };
});
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);
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.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.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}`);
});
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.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');
});
});
});

View File

@@ -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
// {

View File

@@ -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`
<SecretEngine::ConfigureAzure @model={{this.config}} @issuerConfig={{this.issuerConfig}} @backendPath={{this.id}} />
`);
};
});
module('Create view', function () {
module('isEnterprise', function (hooks) {
hooks.beforeEach(function () {
this.version.type = 'enterprise';
});
test('it renders default fields, showing access type options for enterprise users', async function (assert) {
await this.renderComponent();
assert.dom(SES.configureForm).exists('it lands on the Azure configuration form.');
assert.dom(SES.wif.accessType('azure')).isChecked('defaults to showing Azure access type checked');
assert.dom(SES.wif.accessType('wif')).isNotChecked('wif access type is not checked');
// check all the form fields are present
for (const key of expectedConfigKeys('azure-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);
});
});
});
});

View File

@@ -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[];
};