Add GCP configuration details (#29247)

* starting

* add the details functionality

* test coverage

* welp, friday fingers

* small small changes

* Update ui/app/models/gcp/config.js

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

* Update ui/app/helpers/mountable-secret-engines.js

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

* update small changes on model

* reorder loop on configuration details

* Update ui/tests/integration/components/secret-engine/configuration-details-test.js

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

* Update ui/app/models/gcp/config.js

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

* Update ui/app/models/gcp/config.js

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

* Update ui/app/routes/vault/cluster/secrets/backend/configuration/index.js

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

* add comment

---------

Co-authored-by: claire bontempo <68122737+hellobontempo@users.noreply.github.com>
This commit is contained in:
Angel Garbarino
2025-01-02 13:33:14 -07:00
committed by GitHub
parent b5f2accc1d
commit a3e977745f
11 changed files with 388 additions and 52 deletions

View File

@@ -0,0 +1,26 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import ApplicationAdapter from '../application';
import { encodePath } from 'vault/utils/path-encoding-helpers';
export default class GcpConfig extends ApplicationAdapter {
namespace = 'v1';
_url(backend) {
return `${this.buildURL()}/${encodePath(backend)}/config`;
}
queryRecord(store, type, query) {
const { backend } = query;
return this.ajax(this._url(backend), 'GET').then((resp) => {
return {
...resp,
id: backend,
backend,
};
});
}
}

View File

@@ -30,12 +30,15 @@
@title="{{@typeDisplay}} not configured"
@message="Get started by configuring your {{@typeDisplay}} secrets engine."
>
<Hds::Link::Standalone
@icon="chevron-right"
@iconPosition="trailing"
@text="Configure {{@typeDisplay}}"
@route="vault.cluster.secrets.backend.configuration.edit"
@model={{@id}}
/>
{{! 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}}
</EmptyState>
{{/each}}

View File

@@ -142,8 +142,7 @@ export function wifEngines() {
}
// The UI only supports configuration views for these secrets engines. The CLI must be used to manage other engine resources (i.e. roles, credentials).
// Will eventually include gcp.
export const CONFIGURATION_ONLY = ['azure'];
export const CONFIGURATION_ONLY = ['azure', 'gcp'];
export function configurationOnly() {
return CONFIGURATION_ONLY.slice();
@@ -151,7 +150,7 @@ export function configurationOnly() {
// Secret engines that have their own configuration page and actions
// These engines do not exist in their own Ember engine.
export const CONFIGURABLE_SECRET_ENGINES = ['aws', 'azure', 'ssh'];
export const CONFIGURABLE_SECRET_ENGINES = ['aws', 'azure', 'gcp', 'ssh'];
export function configurableSecretEngines() {
return CONFIGURABLE_SECRET_ENGINES.slice();
@@ -161,7 +160,7 @@ export function mountableEngines() {
return MOUNTABLE_SECRET_ENGINES.slice();
}
// secret engines that have not other views than the mount view and mount details view
export const UNSUPPORTED_ENGINES = ['alicloud', 'consul', 'gcp', 'gcpkms', 'nomad', 'rabbitmq', 'totp'];
export const UNSUPPORTED_ENGINES = ['alicloud', 'consul', 'gcpkms', 'nomad', 'rabbitmq', 'totp'];
export function unsupportedEngines() {
return UNSUPPORTED_ENGINES.slice();

View File

@@ -10,6 +10,7 @@ const SUPPORTED_SECRET_BACKENDS = [
'azure',
'cubbyhole',
'database',
'gcp',
'generic',
'keymgmt',
'kmip',

View File

@@ -0,0 +1,71 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import Model, { attr } from '@ember-data/model';
import { 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
/* GCP config fields */
@attr({
label: 'Config TTL',
editType: 'ttl',
helperTextDisabled: 'The TTL (time-to-live) of generated tokens.',
})
ttl;
@attr({
label: 'Max TTL',
editType: 'ttl',
helperTextDisabled:
'Specifies the maximum config TTL (time-to-live) for long-lived credentials (i.e. service account keys).',
})
maxTtl;
@attr('string', {
label: 'JSON credentials',
subText:
'If empty, Vault will use the GOOGLE_APPLICATION_CREDENTIALS environment variable if configured.',
editType: 'file',
docLink: '/vault/docs/secrets/gcp#authentication',
})
credentials; // obfuscated, never returned by API.
/* WIF config fields */
@attr('string', {
subText:
'The audience claim value for plugin identity tokens. Must match an allowed audience configured for the target IAM OIDC identity provider.',
})
identityTokenAudience;
@attr({
label: 'Identity token TTL',
helperTextDisabled:
'The TTL of generated tokens. Defaults to 1 hour, toggle on to specify a different value.',
helperTextEnabled: 'The TTL of generated tokens.',
editType: 'ttl',
})
identityTokenTtl;
@attr('string', {
subText: 'Email ID for the Service Account to impersonate for Workload Identity Federation.',
})
serviceAccountEmail;
configurableParams = [
'credentials',
'serviceAccountEmail',
'ttl',
'maxTtl',
'identityTokenAudience',
'identityTokenTtl',
];
get displayAttrs() {
const formFields = expandAttributeMeta(this, this.configurableParams);
return formFields.filter((attr) => attr.name !== 'credentials');
}
}

View File

@@ -63,13 +63,16 @@ export default class SecretsBackendConfigurationRoute extends Route {
}
fetchConfig(type, id) {
// id is the path where the backend is mounted since there's only one config per engine (often this path is referred to just as backend)
switch (type) {
case 'aws':
return this.fetchAwsConfigs(id);
case 'ssh':
return this.fetchSshCaConfig(id);
case 'azure':
return this.fetchAzureConfig(id);
case 'gcp':
return this.fetchGcpConfig(id);
case 'ssh':
return this.fetchSshCaConfig(id);
default:
return reject({ httpStatus: 404, message: 'not found', path: id });
}
@@ -104,28 +107,6 @@ export default class SecretsBackendConfigurationRoute extends Route {
}
}
async fetchIssuer() {
try {
return await this.store.queryRecord('identity/oidc/config', {});
} catch (e) {
// silently fail if the endpoint is not available or the user doesn't have permission to access it.
return;
}
}
async fetchSshCaConfig(id) {
try {
return await this.store.queryRecord('ssh/ca-config', { backend: id });
} catch (e) {
if (e.httpStatus === 400 && e.errors[0] === `keys haven't been configured yet`) {
// When first mounting a SSH engine it throws a 400 error with this specific message.
// We want to catch this situation and return nothing so that the component can handle it correctly.
return;
}
throw e;
}
}
async fetchAzureConfig(id) {
try {
const azureModel = await this.store.queryRecord('azure/config', { backend: id });
@@ -149,6 +130,49 @@ export default class SecretsBackendConfigurationRoute extends Route {
}
}
async fetchGcpConfig(id) {
try {
const gcpModel = await this.store.queryRecord('gcp/config', { backend: id });
let issuer = null;
if (this.version.isEnterprise) {
const WIF_FIELDS = ['identityTokenAudience', 'identityTokenTtl', 'serviceAccountEmail'];
WIF_FIELDS.some((field) => gcpModel[field]) ? (issuer = await this.fetchIssuer()) : null;
}
const configArray = [];
if (gcpModel) configArray.push(gcpModel);
if (issuer) configArray.push(issuer);
return configArray;
} catch (e) {
if (e.httpStatus === 404) {
// a 404 error is thrown when GCP's config hasn't been set yet.
return;
}
throw e;
}
}
async fetchIssuer() {
try {
return await this.store.queryRecord('identity/oidc/config', {});
} catch (e) {
// silently fail if the endpoint is not available or the user doesn't have permission to access it.
return;
}
}
async fetchSshCaConfig(id) {
try {
return await this.store.queryRecord('ssh/ca-config', { backend: id });
} catch (e) {
if (e.httpStatus === 400 && e.errors[0] === `keys haven't been configured yet`) {
// When first mounting a SSH engine it throws a 400 error with this specific message.
// We want to catch this situation and return nothing so that the component can handle it correctly.
return;
}
throw e;
}
}
setupController(controller, resolvedModel) {
super.setupController(controller, resolvedModel);
controller.typeDisplay = allEngines().find(

View File

@@ -0,0 +1,23 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import ApplicationSerializer from '../application';
export default class GcpConfigSerializer extends ApplicationSerializer {
normalizeResponse(store, primaryModelClass, payload, id, requestType) {
if (!payload.data) {
return super.normalizeResponse(...arguments);
}
const normalizedPayload = {
id: payload.id,
backend: payload.backend,
data: {
...payload.data,
},
};
return super.normalizeResponse(store, primaryModelClass, normalizedPayload, id, requestType);
}
}

View File

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

View File

@@ -0,0 +1,130 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import { click, visit, currentURL } from '@ember/test-helpers';
import { module, test } from 'qunit';
import { setupApplicationTest } from 'ember-qunit';
import { v4 as uuidv4 } from 'uuid';
import authPage from 'vault/tests/pages/auth';
import enablePage from 'vault/tests/pages/settings/mount-secret-backend';
import { setupMirage } from 'ember-cli-mirage/test-support';
import { runCmd } from 'vault/tests/helpers/commands';
import { GENERAL } from 'vault/tests/helpers/general-selectors';
import { overrideResponse } from 'vault/tests/helpers/stubs';
import { SECRET_ENGINE_SELECTORS as SES } from 'vault/tests/helpers/secret-engine/secret-engine-selectors';
import { mountBackend } from 'vault/tests/helpers/components/mount-backend-form-helpers';
import {
expectedConfigKeys,
expectedValueOfConfigKeys,
configUrl,
} from 'vault/tests/helpers/secret-engine/secret-engine-helpers';
module('Acceptance | GCP | configuration', function (hooks) {
setupApplicationTest(hooks);
setupMirage(hooks);
hooks.beforeEach(function () {
this.store = this.owner.lookup('service:store');
this.version = this.owner.lookup('service:version');
this.uid = uuidv4();
this.type = 'gcp';
return authPage.login();
});
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 GCP engine', async function (assert) {
const path = `GCP-${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('Google Cloud not configured');
assert.dom(GENERAL.emptyStateActions).doesNotContainText('Configure GCP');
// cleanup
await runCmd(`delete sys/mounts/${path}`);
});
test('it should not show "Configure" from toolbar', async function (assert) {
const path = `GCP-${this.uid}`;
await enablePage.enable(this.type, path);
assert.dom(SES.configure).doesNotExist('Configure button does not exist.');
// cleanup
await runCmd(`delete sys/mounts/${path}`);
});
test('it should show configuration details with WIF options configured', async function (assert) {
const path = `GCP-${this.uid}`;
const wifAttrs = {
service_account_email: 'service-email',
identity_token_audience: 'audience',
identity_token_ttl: 720000,
};
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('gcp-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('Path'))
.hasText(`${path}/`, 'mount path is displayed in the configuration details');
// cleanup
await runCmd(`delete sys/mounts/${path}`);
});
test('it should show configuration details with GCP account options configured', async function (assert) {
const path = `GCP-${this.uid}`;
const GCPAccountAttrs = {
credentials: '{"some-key":"some-value"}',
ttl: '1 hour',
max_ttl: '4 hours',
};
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, ...GCPAccountAttrs } };
});
await enablePage.enable(this.type, path);
for (const key of expectedConfigKeys(this.type)) {
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 = `GCP-${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');
});
});
});

View File

@@ -148,6 +148,35 @@ const createAzureConfig = (store, backend, accessType = 'generic') => {
return store.peekRecord('azure/config', backend);
};
const createGcpConfig = (store, backend, accessType = 'gcp') => {
// clear any records first
store.unloadAll('gcp/config');
if (accessType === 'wif') {
store.pushPayload('gcp/config', {
id: backend,
modelName: 'gcp/config',
data: {
backend,
service_account_email: 'service-email',
identity_token_audience: 'audience',
identity_token_ttl: 7200,
},
});
} else {
store.pushPayload('gcp/config', {
id: backend,
modelName: 'gcp/config',
data: {
backend,
credentials: '{"some-key":"some-value"}',
ttl: '1 hour',
max_ttl: '4 hours',
},
});
}
return store.peekRecord('gcp/config', backend);
};
export function configUrl(type, backend) {
switch (type) {
case 'aws':
@@ -181,6 +210,8 @@ export const createConfig = (store, backend, type) => {
return createAzureConfig(store, backend, 'wif');
case 'azure-generic':
return createAzureConfig(store, backend, 'generic');
case 'gcp':
return createGcpConfig(store, backend);
}
};
// Used in tests to assert the expected keys in the config details of configurable secret engines
@@ -220,6 +251,12 @@ export const expectedConfigKeys = (type) => {
'identityTokenAudience',
'Identity token TTL',
];
case 'gcp':
return ['Config TTL', 'Max TTL'];
case 'gcp-wif':
return ['Service account email', 'Identity token audience', 'Identity token TTL'];
case 'gcp-wif-camelCase':
return ['serviceAccountEmail', 'identityTokenAudience', 'Identity token TTL'];
}
};
@@ -257,6 +294,23 @@ const valueOfAzureKeys = (string) => {
}
};
const valueOfGcpKeys = (string) => {
switch (string) {
case 'Credentials':
return '"{"some-key":"some-value"}",';
case 'Service account email':
return 'service-email';
case 'Config TTL':
return '1 hour';
case 'Max TTL':
return '4 hours';
case 'Identity token audience':
return 'audience';
case 'Identity token TTL':
return '8 days 8 hours';
}
};
const valueOfSshKeys = (string) => {
switch (string) {
case 'Public key':
@@ -272,6 +326,8 @@ export const expectedValueOfConfigKeys = (type, string) => {
return valueOfAwsKeys(string);
case 'azure':
return valueOfAzureKeys(string);
case 'gcp':
return valueOfGcpKeys(string);
case 'ssh':
return valueOfSshKeys(string);
}

View File

@@ -16,6 +16,8 @@ import {
expectedValueOfConfigKeys,
} from 'vault/tests/helpers/secret-engine/secret-engine-helpers';
const allEnginesArray = allEngines(); // saving as const so we don't invoke the method multiple times via the for loop
module('Integration | Component | SecretEngine/ConfigurationDetails', function (hooks) {
setupRenderingTest(hooks);
@@ -35,10 +37,8 @@ module('Integration | Component | SecretEngine/ConfigurationDetails', function (
.hasText(`Get started by configuring your Display Name secrets engine.`);
});
test('it shows config details if configModel(s) are passed in', async function (assert) {
assert.expect(36);
const allEnginesArray = allEngines(); // saving as const so we don't invoke the method multiple times via the for loop
for (const type of CONFIGURABLE_SECRET_ENGINES) {
for (const type of CONFIGURABLE_SECRET_ENGINES) {
test(`it shows config details if configModel(s) are passed in for type: ${type}`, async function (assert) {
const backend = `test-${type}`;
this.configModels = createConfig(this.store, backend, type);
this.typeDisplay = allEnginesArray.find((engine) => engine.type === type).displayName;
@@ -60,6 +60,6 @@ module('Integration | Component | SecretEngine/ConfigurationDetails', function (
assert.dom(GENERAL.infoRowValue(key)).doesNotHaveClass('masked-input', `${key} is not masked`);
}
}
}
});
});
}
});