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 edit/create of Gcp Secret engine #29298

Draft
wants to merge 2 commits into
base: main
Choose a base branch
from
Draft
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
20 changes: 20 additions & 0 deletions ui/app/adapters/gcp/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,4 +23,24 @@ export default class GcpConfig 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);
}
}
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 gcp is merged. }}
{{#unless (eq @typeDisplay "Google Cloud")}}
<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-gcp.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 gcp credentials" data-test-configure-form>
<NamespaceReminder @mode="save" @noun="configuration" />
<MessageError @errorMessage={{this.errorMessage}} />
<div class="box is-fullwidth is-sideless">
{{! accessType can be "gcp" or "wif" - since WIF is an enterprise only feature we default to "gcp" 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 GCP. Access can be configured either using an GCP account or with the
Plugin Workload Identity Federation (WIF).
{{/if}}
</p>
<div>
<RadioButton
id="access-type-gcp"
name="gcp"
class="radio"
data-test-access-type="gcp"
@value="gcp"
@groupValue={{this.accessType}}
@onChange={{fn this.onChangeAccessType "gcp"}}
@disabled={{this.disableAccessType}}
/>
<label for="access-type-gcp">GCP 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}}
{{! GCP Fields }}
<FormFieldGroups
@model={{@model}}
@mode={{if @model.isNew "create" "edit"}}
@useEnableInput={{true}}
@groupName="fieldGroupsGcp"
/>
{{/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}}
183 changes: 183 additions & 0 deletions ui/app/components/secret-engine/configure-gcp.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
/**
* 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/gcp/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 SecretEngineConfigureGcp component is used to configure the Gcp secret engine
* For enterprise users, they will see an additional option to config WIF attributes in place of GCP credentials.
* 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::ConfigureGcp
@model={{this.model.gcp-config}}
@backendPath={{this.model.id}}
@issuerConfig={{this.model.identity-oidc-config}}
/>
*
* @param {object} model - GCP config model
* @param {string} backendPath - name of the Gcp secret engine, ex: 'gcp-123'
* @param {object} issuerConfigModel - the identity/oidc/config model
*/

interface Args {
model: ConfigModel;
issuerConfig: IdentityOidcConfigModel;
backendPath: string;
}

export default class ConfigureGcpComponent 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 = 'gcp';
@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 } = this.args.model;
this.accessType = isWifPluginConfigured ? 'wif' : 'gcp';
this.disableAccessType = isWifPluginConfigured;
}

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 === 'gcp') {
// 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 Gcp attributes
model.credentials = undefined;
}
}

@action
onCancel() {
this.resetErrors();
this.args.model.unloadRecord();
this.transition();
}
}
2 changes: 1 addition & 1 deletion ui/app/helpers/mountable-secret-engines.js
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,7 @@ const MOUNTABLE_SECRET_ENGINES = [
];

// A list of Workload Identity Federation engines.
export const WIF_ENGINES = ['aws', 'azure'];
export const WIF_ENGINES = ['aws', 'azure', 'gcp'];

export function wifEngines() {
return WIF_ENGINES.slice();
Expand Down
37 changes: 36 additions & 1 deletion ui/app/models/gcp/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';

export default class GcpConfig extends Model {
@attr('string') backend; // dynamic path of secret -- set on response from value passed to queryRecord
Expand Down Expand Up @@ -64,8 +64,43 @@ export default class GcpConfig extends Model {
'identityTokenTtl',
];

get isWifPluginConfigured() {
return !!this.identityTokenAudience || !!this.identityTokenTtl || !!this.serviceAccountEmail;
}

get isAccountPluginConfigured() {
return !!this.credentials;
}

get displayAttrs() {
const formFields = expandAttributeMeta(this, this.configurableParams);
return formFields.filter((attr) => attr.name !== 'credentials');
}

// "filedGroupsWif" and "fieldGroupsGcp" 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 fieldGroupsAccount() {
return fieldToAttrs(this, this.formFieldGroups('account'));
}

formFieldGroups(accessType = 'account') {
const formFieldGroups = [];
if (accessType === 'wif') {
formFieldGroups.push({
default: ['identityTokenAudience', 'serviceAccountEmail', 'identityTokenTtl'],
});
}
if (accessType === 'account') {
formFieldGroups.push({
default: ['credentials'],
});
}
formFieldGroups.push({
'More options': ['ttl', 'maxTtl'],
});
return formFieldGroups;
}
}
Loading
Loading