diff --git a/ui/app/adapters/aws/lease-config.js b/ui/app/adapters/aws/lease-config.js new file mode 100644 index 0000000000..b7dac0df84 --- /dev/null +++ b/ui/app/adapters/aws/lease-config.js @@ -0,0 +1,20 @@ +/** + * 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 AwsRootConfig extends ApplicationAdapter { + namespace = 'v1'; + // For now this is only being used on the vault.cluster.secrets.backend.configuration route. This is a read-only route. + // Eventually, this will be used to create the lease config for the AWS secret backend, replacing the requests located on the secret-engine adapter. + queryRecord(store, type, query) { + const { backend } = query; + return this.ajax(`${this.buildURL()}/${encodePath(backend)}/config/lease`, 'GET').then((resp) => { + resp.id = backend; + return resp; + }); + } +} diff --git a/ui/app/components/secret-engine/configuration-details.hbs b/ui/app/components/secret-engine/configuration-details.hbs index 4f8ff07eda..9ed62214dd 100644 --- a/ui/app/components/secret-engine/configuration-details.hbs +++ b/ui/app/components/secret-engine/configuration-details.hbs @@ -3,42 +3,46 @@ SPDX-License-Identifier: BUSL-1.1 ~}} -{{#if this.configError}} - {{! Surface API errors not associated with empty configuration details }} - -{{else if this.configModel}} - {{#each this.configModel.attrs as |attr|}} - {{#if attr.options.sensitive}} - - {{#if attr.options.sensitive}} - - {{/if}} - - {{else}} - - {{/if}} +{{#if @configModels.length}} + {{#each @configModels as |configModel|}} + {{#each configModel.attrs as |attr|}} + {{#if attr.options.sensitive}} + + {{#if attr.options.sensitive}} + + {{/if}} + + {{else}} + + {{/if}} + {{/each}} {{/each}} {{else}} - {{! Prompt for a user to configure the secret engine }} + {{! Prompt user to configure the secret engine }} {{/if}} \ No newline at end of file diff --git a/ui/app/components/secret-engine/configuration-details.ts b/ui/app/components/secret-engine/configuration-details.ts deleted file mode 100644 index debb4a48e3..0000000000 --- a/ui/app/components/secret-engine/configuration-details.ts +++ /dev/null @@ -1,94 +0,0 @@ -/** - * Copyright (c) HashiCorp, Inc. - * SPDX-License-Identifier: BUSL-1.1 - */ - -import { service } from '@ember/service'; -import Component from '@glimmer/component'; -import { tracked } from '@glimmer/tracking'; -import { allEngines } from 'vault/helpers/mountable-secret-engines'; - -import type Store from '@ember-data/store'; -import type SecretEngineModel from 'vault/models/secret-engine'; -import type AdapterError from '@ember-data/adapter'; -import type Model from '@ember-data/model'; - -/** - * @module ConfigurationDetails - * `ConfigurationDetails` is used by configurable secret engines (AWS, SSH) to show either an API error, configuration details, or a prompt to configure the engine. Which of these is shown is determined by the engine type and whether the user has configured the engine yet. - * - * @example - * ```js - * - * ``` - * - * @param {object} model - The secret-engine model to be configured. - */ - -interface Args { - model: SecretEngineModel | null; -} - -interface ConfigError { - httpStatus: number | null; - message: string | null; - errors: object | null; -} - -export default class ConfigurationDetails extends Component { - @service declare readonly store: Store; - @tracked configError: ConfigError | null = null; - @tracked configModel: Model | null = null; - - constructor(owner: unknown, args: Args) { - super(owner, args); - const { model } = this.args; - // Should not be able to get here without a model, but in case an upstream change allows it, handle the error higher up. - if (!model) { - return; - } - const { id, type } = model; - // Fetch the config for the engine type. - switch (type) { - case 'aws': - this.fetchAwsRootConfig(id); - break; - case 'ssh': - this.fetchSshCaConfig(id); - break; - } - } - - async fetchAwsRootConfig(backend: string) { - try { - this.configModel = await this.store.queryRecord('aws/root-config', { backend }); - } catch (e: AdapterError) { - // If the error is something other than 404 "not found" then an API error has come back and this will be displayed to the user as an error. - // If it's 404 then configError is not set nor is the configModel and a prompt to configure will be shown. - if (e.httpStatus !== 404) { - this.configError = e; - } - return; - } - } - - async fetchSshCaConfig(backend: string) { - try { - this.configModel = await this.store.queryRecord('ssh/ca-config', { backend }); - } catch (e: AdapterError) { - // The SSH api does not return a 404 not found but a 400 error after first mounting the engine with the - // message that keys have not been configured yet. - // We need to check the message of the 400 error and if it's the keys message, return a prompt instead of a configError. - if (e.httpStatus !== 404 && e.errors[0] !== `keys haven't been configured yet`) { - this.configError = e; - } - return; - } - } - - get typeDisplay() { - if (!this.args.model) return; - const { type } = this.args.model; - return allEngines().find((engine) => engine.type === type)?.displayName; - } -} diff --git a/ui/app/components/configure-aws-secret.hbs b/ui/app/components/secret-engine/configure-aws.hbs similarity index 100% rename from ui/app/components/configure-aws-secret.hbs rename to ui/app/components/secret-engine/configure-aws.hbs diff --git a/ui/app/components/configure-aws-secret.ts b/ui/app/components/secret-engine/configure-aws.ts similarity index 93% rename from ui/app/components/configure-aws-secret.ts rename to ui/app/components/secret-engine/configure-aws.ts index a941586fc0..0368d25e92 100644 --- a/ui/app/components/configure-aws-secret.ts +++ b/ui/app/components/secret-engine/configure-aws.ts @@ -10,11 +10,11 @@ import type SecretEngineModel from 'vault/models/secret-engine'; import type { TtlEvent } from 'vault/app-types'; /** - * @module ConfigureAwsSecretComponent + * @module ConfigureAwsComponent * * @example * ```js - * void; } -export default class ConfigureAwsSecretComponent extends Component { +export default class ConfigureAwsComponent extends Component { @action saveRootCreds(data: AWSRootCredsFields, event: Event) { event.preventDefault(); diff --git a/ui/app/templates/components/configure-ssh-secret.hbs b/ui/app/components/secret-engine/configure-ssh.hbs similarity index 100% rename from ui/app/templates/components/configure-ssh-secret.hbs rename to ui/app/components/secret-engine/configure-ssh.hbs diff --git a/ui/app/components/configure-ssh-secret.js b/ui/app/components/secret-engine/configure-ssh.js similarity index 85% rename from ui/app/components/configure-ssh-secret.js rename to ui/app/components/secret-engine/configure-ssh.js index a6ea08c6f8..c58981e43a 100644 --- a/ui/app/components/configure-ssh-secret.js +++ b/ui/app/components/secret-engine/configure-ssh.js @@ -7,11 +7,11 @@ import Component from '@glimmer/component'; import { action } from '@ember/object'; /** - * @module ConfigureSshSecretComponent + * @module ConfigureSshSComponent * * @example * ```js - * response.canRead); // only set these config params if they can read the config endpoint. if (canRead) { // design wants specific default to show that can't be set in the model - backend.casRequired = backend.casRequired ? backend.casRequired : 'False'; - backend.deleteVersionAfter = backend.deleteVersionAfter ? backend.deleteVersionAfter : 'Never delete'; + secretEngineModel.casRequired = secretEngineModel.casRequired + ? secretEngineModel.casRequired + : 'False'; + secretEngineModel.deleteVersionAfter = secretEngineModel.deleteVersionAfter + ? secretEngineModel.deleteVersionAfter + : 'Never delete'; } else { // remove the default values from the model if they don't have read access otherwise it will display the defaults even if they've been set (because they error on returning config data) - backend.set('casRequired', null); - backend.set('deleteVersionAfter', null); - backend.set('maxVersions', null); + secretEngineModel.set('casRequired', null); + secretEngineModel.set('deleteVersionAfter', null); + secretEngineModel.set('maxVersions', null); } } - return backend; + // If the engine is configurable fetch the config model(s) for the engine and return it alongside the model + if (CONFIGURABLE_SECRET_ENGINES.includes(secretEngineModel.type)) { + let configModels = await this.fetchConfig(secretEngineModel.type, secretEngineModel.id); + configModels = this.standardizeConfigModels(configModels); + + return { + secretEngineModel, + configModels, + }; + } + return { secretEngineModel }; + } + + standardizeConfigModels(configModels) { + // standardize the configModels to an array so that the component can handle it correctly + Array.isArray(configModels) ? configModels : (configModels = [configModels]); + // make sure no items in the array are null or undefined + return configModels.filter((configModel) => { + return !!configModel; + }); + } + + fetchConfig(type, id) { + switch (type) { + case 'aws': + return this.fetchAwsConfigs(id); + case 'ssh': + return this.fetchSshCaConfig(id); + default: + return reject({ httpStatus: 404, message: 'not found', path: id }); + } + } + + async fetchAwsConfigs(id) { + // AWS has two configuration endpoints root and lease, return an array of these responses. + const configArray = []; + const configRoot = await this.fetchAwsConfig(id, 'aws/root-config'); + const configLease = await this.fetchAwsConfig(id, 'aws/lease-config'); + configArray.push(configRoot, configLease); + return configArray; + } + + async fetchAwsConfig(id, modelPath) { + try { + return await this.store.queryRecord(modelPath, { backend: id }); + } catch (e) { + if (e.httpStatus === 404) { + // a 404 error is thrown when the lease config hasn't been set yet. + return; + } + throw e; + } + } + + 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( + (engine) => engine.type === resolvedModel.secretEngineModel.type + )?.displayName; + controller.isConfigurable = CONFIGURABLE_SECRET_ENGINES.includes(resolvedModel.secretEngineModel.type); + controller.modelId = resolvedModel.secretEngineModel.id; } } 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 61ccf0e1b5..9a357e2ee1 100644 --- a/ui/app/templates/vault/cluster/secrets/backend/configuration/edit.hbs +++ b/ui/app/templates/vault/cluster/secrets/backend/configuration/edit.hbs @@ -29,7 +29,7 @@ {{#if (eq this.model.type "aws")}} - {{else if (eq this.model.type "ssh")}} - @@ -19,7 +19,7 @@ Configure @@ -27,24 +27,36 @@ - - + + + {{else}}
- {{#each this.model.attrs as |attr|}} + {{#each this.model.secretEngineModel.attrs as |attr|}} {{#if (eq attr.type "object")}} {{else}} {{/if}} {{/each}} diff --git a/ui/tests/acceptance/secrets/backend/aws/aws-configuration-test.js b/ui/tests/acceptance/secrets/backend/aws/aws-configuration-test.js index e96b76928b..8982c76ee5 100644 --- a/ui/tests/acceptance/secrets/backend/aws/aws-configuration-test.js +++ b/ui/tests/acceptance/secrets/backend/aws/aws-configuration-test.js @@ -14,11 +14,13 @@ 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 { createConfig, expectedConfigKeys, expectedValueOfConfigKeys, + configUrl, } from 'vault/tests/helpers/secret-engine/secret-engine-helpers'; module('Acceptance | aws | configuration', function (hooks) { @@ -82,18 +84,18 @@ module('Acceptance | aws | configuration', function (hooks) { await click(SES.configure); await fillIn(GENERAL.inputByAttr('accessKey'), 'foo'); await fillIn(GENERAL.inputByAttr('secretKey'), 'bar'); - this.server.post(`${path}/config/root`, (schema, req) => { - const payload = JSON.parse(req.requestBody); - assert.deepEqual(payload.access_key, 'foo', 'access_key is foo'); - assert.deepEqual(payload.secret_key, 'bar', 'secret_key is foo'); - return { data: { id: path, type: 'aws', attributes: payload } }; - }); await click(GENERAL.saveButtonId('root')); assert.true( this.flashSuccessSpy.calledWith('The backend configuration saved successfully!'), 'Success flash message is rendered' ); + + await visit(`/vault/secrets/${path}/configuration`); + assert.dom(GENERAL.infoRowValue('Access key')).hasText('foo', `Access Key has been set.`); + assert + .dom(GENERAL.infoRowValue('Secret key')) + .doesNotExist(`Secret key is not shown because it does not get returned by the api.`); // cleanup await runCmd(`delete sys/mounts/${path}`); }); @@ -101,12 +103,6 @@ module('Acceptance | aws | configuration', function (hooks) { test('it should save lease AWS configuration', async function (assert) { assert.expect(3); const path = `aws-${this.uid}`; - this.server.post(`${path}/config/lease`, (schema, req) => { - const payload = JSON.parse(req.requestBody); - assert.deepEqual(payload.lease, '55s', 'lease is set to 55s'); - assert.deepEqual(payload.lease_max, '65s', 'maximum_lease is set to 65s'); - return { data: { id: path, type: 'aws', attributes: payload } }; - }); await enablePage.enable('aws', path); await click(SES.configTab); await click(SES.configure); @@ -120,11 +116,16 @@ module('Acceptance | aws | configuration', function (hooks) { this.flashSuccessSpy.calledWith('The backend configuration saved successfully!'), 'Success flash message is rendered' ); + + await visit(`/vault/secrets/${path}/configuration`); + assert.dom(GENERAL.infoRowValue('Default Lease TTL')).hasText('55s', `Default TTL has been set.`); + assert.dom(GENERAL.infoRowValue('Max Lease TTL')).hasText('1m5s', `Default TTL has been set.`); + // cleanup await runCmd(`delete sys/mounts/${path}`); }); - test('it show AWS configuration details', async function (assert) { + test('it shows AWS mount configuration details', async function (assert) { assert.expect(12); const path = `aws-${this.uid}`; const type = 'aws'; @@ -153,7 +154,7 @@ module('Acceptance | aws | configuration', function (hooks) { }); test('it should update AWS configuration details after editing', async function (assert) { - assert.expect(4); + assert.expect(6); const path = `aws-${this.uid}`; const type = 'aws'; await enablePage.enable(type, path); @@ -168,17 +169,40 @@ module('Acceptance | aws | configuration', function (hooks) { assert .dom(GENERAL.infoRowValue('Region')) .doesNotExist('Region has not been added therefor it does not show up on the details view.'); - // edit accessKey and another field and confirm the details page is updated. + // edit root config details and lease config details and confirm the configuration.index page is updated. await click(SES.configure); await fillIn(GENERAL.inputByAttr('accessKey'), 'hello'); await click(GENERAL.menuTrigger); await fillIn(GENERAL.selectByAttr('region'), 'ca-central-1'); await click(GENERAL.saveButtonId('root')); + // add lease config details + await click(GENERAL.hdsTab('lease')); + await click(GENERAL.toggleInput('Lease')); + await fillIn(GENERAL.ttl.input('Lease'), '33'); + await click(GENERAL.toggleInput('Maximum Lease')); + await fillIn(GENERAL.ttl.input('Maximum Lease'), '43'); + await click(GENERAL.saveButtonId('lease')); + await click(SES.viewBackend); await click(SES.configTab); assert.dom(GENERAL.infoRowValue('Access key')).hasText('hello', 'Access key has been updated to hello'); assert.dom(GENERAL.infoRowValue('Region')).hasText('ca-central-1', 'Region has been added'); + assert.dom(GENERAL.infoRowValue('Default Lease TTL')).hasText('33s', 'Default Lease TTL has been added'); + assert.dom(GENERAL.infoRowValue('Max Lease TTL')).hasText('43s', 'Max Lease TTL has been added'); // cleanup await runCmd(`delete sys/mounts/${path}`); }); + + test('it should show API error when AWS configuration read fails', async function (assert) { + assert.expect(1); + const path = `aws-${this.uid}`; + const type = 'aws'; + await enablePage.enable(type, path); + // interrupt get and return API error + this.server.get(configUrl(type, path), () => { + return overrideResponse(400, { errors: ['bad request'] }); + }); + await click(SES.configTab); + assert.dom(SES.error.title).hasText('Error', 'shows the secrets backend error route'); + }); }); diff --git a/ui/tests/acceptance/secrets/backend/ssh/ssh-configuration-test.js b/ui/tests/acceptance/secrets/backend/ssh/ssh-configuration-test.js index 46a8def541..9fb8544726 100644 --- a/ui/tests/acceptance/secrets/backend/ssh/ssh-configuration-test.js +++ b/ui/tests/acceptance/secrets/backend/ssh/ssh-configuration-test.js @@ -10,12 +10,16 @@ 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 { SECRET_ENGINE_SELECTORS as SES } from 'vault/tests/helpers/secret-engine/secret-engine-selectors'; +import { configUrl } from 'vault/tests/helpers/secret-engine/secret-engine-helpers'; +import { overrideResponse } from 'vault/tests/helpers/stubs'; module('Acceptance | ssh | configuration', function (hooks) { setupApplicationTest(hooks); + setupMirage(hooks); hooks.beforeEach(function () { this.uid = uuidv4(); @@ -82,4 +86,17 @@ module('Acceptance | ssh | configuration', function (hooks) { // cleanup await runCmd(`delete sys/mounts/${sshPath}`); }); + + test('it should show API error when SSH configuration read fails', async function (assert) { + assert.expect(1); + const path = `ssh-${this.uid}`; + const type = 'ssh'; + await enablePage.enable(type, path); + // interrupt get and return API error + this.server.get(configUrl(type, path), () => { + return overrideResponse(400, { errors: ['bad request'] }); + }); + await click(SES.configTab); + assert.dom(SES.error.title).hasText('Error', 'shows the secrets backend error route'); + }); }); diff --git a/ui/tests/helpers/secret-engine/secret-engine-selectors.ts b/ui/tests/helpers/secret-engine/secret-engine-selectors.ts index b33f271c38..5dbc343074 100644 --- a/ui/tests/helpers/secret-engine/secret-engine-selectors.ts +++ b/ui/tests/helpers/secret-engine/secret-engine-selectors.ts @@ -11,6 +11,9 @@ export const SECRET_ENGINE_SELECTORS = { configurationToggle: '[data-test-mount-config-toggle]', createSecret: '[data-test-secret-create]', crumb: (path: string) => `[data-test-secret-breadcrumb="${path}"] a`, + error: { + title: '[data-test-backend-error-title]', + }, generateLink: '[data-test-backend-credentials]', mountType: (name: string) => `[data-test-mount-type="${name}"]`, mountSubmit: '[data-test-mount-submit]', diff --git a/ui/tests/integration/components/secret-engine/configuration-details-test.js b/ui/tests/integration/components/secret-engine/configuration-details-test.js index 54c1e903f3..3d8f429b6c 100644 --- a/ui/tests/integration/components/secret-engine/configuration-details-test.js +++ b/ui/tests/integration/components/secret-engine/configuration-details-test.js @@ -6,70 +6,39 @@ import { module, test } from 'qunit'; import { setupRenderingTest } from 'vault/tests/helpers'; import { GENERAL } from 'vault/tests/helpers/general-selectors'; -import { setupMirage } from 'ember-cli-mirage/test-support'; import { render } from '@ember/test-helpers'; import { hbs } from 'ember-cli-htmlbars'; -import { overrideResponse, allowAllCapabilitiesStub } from 'vault/tests/helpers/stubs'; import { CONFIGURABLE_SECRET_ENGINES } from 'vault/helpers/mountable-secret-engines'; import { - createSecretsEngine, createConfig, - configUrl, expectedConfigKeys, expectedValueOfConfigKeys, } from 'vault/tests/helpers/secret-engine/secret-engine-helpers'; -module('Integration | Component | SecretEngine::configuration-details', function (hooks) { +module('Integration | Component | SecretEngine/configuration-details', function (hooks) { setupRenderingTest(hooks); - setupMirage(hooks); hooks.beforeEach(function () { - this.server.post('/sys/capabilities-self', allowAllCapabilitiesStub()); this.store = this.owner.lookup('service:store'); + this.configModels = []; }); - test('it shows prompt message if no config is returned', async function (assert) { - assert.expect(CONFIGURABLE_SECRET_ENGINES.length * 2); - for (const type of CONFIGURABLE_SECRET_ENGINES) { - const title = type.toUpperCase(); - const backend = `test-404-${type}`; - this.model = createSecretsEngine(this.store, type, backend); - this.server.get(configUrl(type, backend), () => { - return overrideResponse(404); - }); - - await render(hbs``); - assert.dom(GENERAL.emptyStateTitle).hasText(`${title} not configured`); - assert.dom(GENERAL.emptyStateMessage).hasText(`Get started by configuring your ${title} engine.`); - } + test('it shows prompt message if no config models are passed in', async function (assert) { + assert.expect(2); + await render(hbs` + + `); + assert.dom(GENERAL.emptyStateTitle).hasText(`Display Name not configured`); + assert.dom(GENERAL.emptyStateMessage).hasText(`Get started by configuring your Display Name engine.`); }); - test('it shows API error', async function (assert) { - assert.expect(CONFIGURABLE_SECRET_ENGINES.length * 2); - for (const type of CONFIGURABLE_SECRET_ENGINES) { - const backend = `test-400-${type}`; - this.model = createSecretsEngine(this.store, type, backend); - this.server.get(configUrl(type, backend), () => { - return overrideResponse(400, { errors: ['bad request'] }); - }); - - await render(hbs``); - assert.dom(GENERAL.pageError.errorTitle(400)).hasText('Error'); - assert.dom(GENERAL.pageError.errorDetails).hasText('bad request'); - } - }); - - test('it shows config details if config data is returned', async function (assert) { + test('it shows config details if configModel(s) are passed in', async function (assert) { assert.expect(14); for (const type of CONFIGURABLE_SECRET_ENGINES) { const backend = `test-${type}`; - this.model = createSecretsEngine(this.store, type, backend); - createConfig(this.store, backend, type); - this.server.get(configUrl(type, backend), () => { - return overrideResponse(200); - }); + this.configModels = createConfig(this.store, backend, type); - await render(hbs``); + await render(hbs``); for (const key of expectedConfigKeys(type)) { assert.dom(GENERAL.infoRowLabel(key)).exists(`${key} on the ${type} config details exists.`); const responseKeyAndValue = expectedValueOfConfigKeys(type, key);