From d364a87dd03d0159dbebbb62039f9937861f0137 Mon Sep 17 00:00:00 2001 From: Angel Garbarino Date: Mon, 6 Jan 2025 13:38:09 -0700 Subject: [PATCH 1/2] initial work, moving on to try and make on config-engine component --- ui/app/adapters/gcp/config.js | 20 ++ .../secret-engine/configuration-details.hbs | 17 +- .../secret-engine/configure-gcp.hbs | 103 ++++++++++ .../components/secret-engine/configure-gcp.ts | 183 ++++++++++++++++++ ui/app/helpers/mountable-secret-engines.js | 2 +- ui/app/models/gcp/config.js | 34 +++- .../secrets/backend/configuration/edit.ts | 5 +- .../secrets/backend/configuration/edit.hbs | 6 + .../secrets/backend/configuration/index.hbs | 25 ++- ui/types/vault/models/gcp/config.d.ts | 31 +++ 10 files changed, 398 insertions(+), 28 deletions(-) create mode 100644 ui/app/components/secret-engine/configure-gcp.hbs create mode 100644 ui/app/components/secret-engine/configure-gcp.ts create mode 100644 ui/types/vault/models/gcp/config.d.ts diff --git a/ui/app/adapters/gcp/config.js b/ui/app/adapters/gcp/config.js index fd2129be1385..c680969001a1 100644 --- a/ui/app/adapters/gcp/config.js +++ b/ui/app/adapters/gcp/config.js @@ -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); + } } diff --git a/ui/app/components/secret-engine/configuration-details.hbs b/ui/app/components/secret-engine/configuration-details.hbs index 46410ac748bc..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 gcp is merged. }} - {{#unless (eq @typeDisplay "Google Cloud")}} - - {{/unless}} + {{/each}} \ No newline at end of file diff --git a/ui/app/components/secret-engine/configure-gcp.hbs b/ui/app/components/secret-engine/configure-gcp.hbs new file mode 100644 index 000000000000..2dff5be2dcc6 --- /dev/null +++ b/ui/app/components/secret-engine/configure-gcp.hbs @@ -0,0 +1,103 @@ +{{! + Copyright (c) HashiCorp, Inc. + SPDX-License-Identifier: BUSL-1.1 +~}} + +
+ + +
+ {{! 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}} +
+ 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 GCP. Access can be configured either using an GCP 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}} + {{! GCP 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-gcp.ts b/ui/app/components/secret-engine/configure-gcp.ts new file mode 100644 index 000000000000..c6282664a459 --- /dev/null +++ b/ui/app/components/secret-engine/configure-gcp.ts @@ -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 + * + * + * @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 { + @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 { + 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 === '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(); + } +} diff --git a/ui/app/helpers/mountable-secret-engines.js b/ui/app/helpers/mountable-secret-engines.js index 6f9b9d4a5c21..c65221fe0ded 100644 --- a/ui/app/helpers/mountable-secret-engines.js +++ b/ui/app/helpers/mountable-secret-engines.js @@ -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(); diff --git a/ui/app/models/gcp/config.js b/ui/app/models/gcp/config.js index 3bdbcb18abe7..1bcb3f416574 100644 --- a/ui/app/models/gcp/config.js +++ b/ui/app/models/gcp/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'; export default class GcpConfig extends Model { @attr('string') backend; // dynamic path of secret -- set on response from value passed to queryRecord @@ -68,4 +68,36 @@ export default class GcpConfig extends Model { 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 fieldGroupsGcp() { + return fieldToAttrs(this, this.formFieldGroups('gcp')); + } + + formFieldGroups(accessType = 'gcp') { + const formFieldGroups = []; + if (accessType === 'wif') { + formFieldGroups.push({ + default: ['identityTokenAudience', 'serviceAccountEmail', 'identityTokenTtl'], + }); + } + if (accessType === 'gcp') { + formFieldGroups.push({ + default: ['credentials'], + }); + } + formFieldGroups.push({ + 'More options': ['ttl', 'maxTtl'], + }); + return formFieldGroups; + } + + // GETTERS used by configure-gcp component + get isWifPluginConfigured() { + return !!this.identityTokenAudience || !!this.identityTokenTtl || !!this.serviceAccountEmail; + } } 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 e56efa44e4d2..c1115991fcdd 100644 --- a/ui/app/routes/vault/cluster/secrets/backend/configuration/edit.ts +++ b/ui/app/routes/vault/cluster/secrets/backend/configuration/edit.ts @@ -17,11 +17,12 @@ import type VersionService from 'vault/services/version'; // This route file is reused for all configurable secret engines. // It generates config models based on the engine type. -// Saving and updating of those models are done within the engine specific components. +// Saving and updating those models are done within the engine specific components. const CONFIG_ADAPTERS_PATHS: Record = { aws: ['aws/lease-config', 'aws/root-config'], azure: ['azure/config'], + gcp: ['gcp/config'], ssh: ['ssh/ca-config'], }; @@ -40,7 +41,7 @@ export default class SecretsBackendConfigurationEdit extends Route { set(error, 'httpStatus', 404); throw error; } - // generate the model based on the engine type. + // generate the model based on the engine type // and pre-set model with type and backend e.g. {type: ssh, id: ssh-123} const model: Record = { type, id: backend }; for (const adapterPath of CONFIG_ADAPTERS_PATHS[type] as string[]) { 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 80d0c30628d9..30e792472422 100644 --- a/ui/app/templates/vault/cluster/secrets/backend/configuration/edit.hbs +++ b/ui/app/templates/vault/cluster/secrets/backend/configuration/edit.hbs @@ -41,6 +41,12 @@ @backendPath={{this.model.id}} @issuerConfig={{this.model.identity-oidc-config}} /> +{{else if (eq this.model.type "gcp")}} + {{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 3b3c5c4bf9f4..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 gcp is merged. }} - {{#unless (eq this.typeDisplay "Google Cloud")}} - - - - Configure - - - - {{/unless}} + + + + Configure + + + Date: Tue, 7 Jan 2025 12:22:37 -0700 Subject: [PATCH 2/2] wip, shifting to one component --- ui/app/models/gcp/config.js | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/ui/app/models/gcp/config.js b/ui/app/models/gcp/config.js index 1bcb3f416574..c715983fdb45 100644 --- a/ui/app/models/gcp/config.js +++ b/ui/app/models/gcp/config.js @@ -64,6 +64,14 @@ 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'); @@ -74,18 +82,18 @@ export default class GcpConfig extends Model { return fieldToAttrs(this, this.formFieldGroups('wif')); } - get fieldGroupsGcp() { - return fieldToAttrs(this, this.formFieldGroups('gcp')); + get fieldGroupsAccount() { + return fieldToAttrs(this, this.formFieldGroups('account')); } - formFieldGroups(accessType = 'gcp') { + formFieldGroups(accessType = 'account') { const formFieldGroups = []; if (accessType === 'wif') { formFieldGroups.push({ default: ['identityTokenAudience', 'serviceAccountEmail', 'identityTokenTtl'], }); } - if (accessType === 'gcp') { + if (accessType === 'account') { formFieldGroups.push({ default: ['credentials'], }); @@ -95,9 +103,4 @@ export default class GcpConfig extends Model { }); return formFieldGroups; } - - // GETTERS used by configure-gcp component - get isWifPluginConfigured() { - return !!this.identityTokenAudience || !!this.identityTokenTtl || !!this.serviceAccountEmail; - } }