Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Merged
merged 20 commits into from
Dec 18, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions changelog/29047.txt
Original file line number Diff line number Diff line change
@@ -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.
```
20 changes: 20 additions & 0 deletions ui/app/adapters/azure/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,4 +23,24 @@ export default class AzureConfig extends ApplicationAdapter {
};
});
}
Monkeychip marked this conversation as resolved.
Show resolved Hide resolved

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);
}
}
17 changes: 7 additions & 10 deletions ui/app/components/secret-engine/configuration-details.hbs
Original file line number Diff line number Diff line change
Expand Up @@ -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}}
103 changes: 103 additions & 0 deletions ui/app/components/secret-engine/configure-azure.hbs
Original file line number Diff line number Diff line change
@@ -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>
Comment on lines +12 to +46
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since this is reused in AWS curious why we didn't make a shared component?

Copy link
Contributor Author

@Monkeychip Monkeychip Dec 18, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

GCP, AWS and Azure will all have some small overlap because we're adding this "Access Type" option to enterprise views. However, they are some differences, specifically the group values that relate back to these engine's models.

My preference was to keep all configuration code separate in each engines own configuration file. The win for sharing this chunk of code didn't feel significant enough to introduce a shared component that would have to work around each engine's specific "group" or labeling.

{{/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}}
184 changes: 184 additions & 0 deletions ui/app/components/secret-engine/configure-azure.ts
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎉 typescript 🎉 😍

Original file line number Diff line number Diff line change
@@ -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;
Monkeychip marked this conversation as resolved.
Show resolved Hide resolved
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;
}

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You'll see tests coverage for 'modelAttrChanged' and 'issuerAttrChanged' getters in azure-configuration-test acceptance test.

  1. For community "edit" flow, I test the model attr clientSecret.
  2. For the enterprise "create" flow, I test various issuer attribute changes.
  3. And for the enterprise "edit" flow, I test general "wif" attribute changes.

get modelAttrChanged() {
// "backend" dirties model state so explicity ignore it here
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

FWIW I don't think this would happen if you didn't create a new record, and reused the record from queryRecord. But I'm not 💯 sure

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
Copy link
Contributor

@hellobontempo hellobontempo Dec 3, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Historically I've run into some funky ember data stuff with changedAttributes() and hasDirtyAttributes so we'll want to make sure there's ample test coverage around this logic!

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)) {
Copy link
Contributor Author

@Monkeychip Monkeychip Dec 16, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note: we've discussed as a team modifying this workflow. I have that as a nice-to-have ticket as I'll also need to keep the flow in-sync with AWS configuration.

I've added lots of test coverage for all the possible flows.

// 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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

great comment!

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.')}`);
Monkeychip marked this conversation as resolved.
Show resolved Hide resolved
// 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();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

since the clientSecret isn't returned by the API...does saving it completely wipe it out? Wondering if we'll need to perform a patch here on edit instead..

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I did a bunch of testing and this doesn't seem to happen, but not because it shouldn't happen. It's not happening because the record holds onto the clientSecret. I'll revisit this when I have a fresh brain, but I probably need to unload the record on transition and then solve for this.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm 🤔 So when editing a config (not clientSecret) and then save as long as there is no clientSecret sent in the payload then that should be fine. I know sometimes the JSON serializer automatically removes empty values and doesn't include them in the payload, so that may be working to our advantage here.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll make sure there is test coverage here to confirm my smoke testing

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();
}
}
4 changes: 4 additions & 0 deletions ui/app/helpers/mountable-secret-engines.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
Expand Down
Loading
Loading