From 15ed6007d021ffca9fba754841c13c94d5f2014a Mon Sep 17 00:00:00 2001 From: Vault Automation Date: Thu, 18 Sep 2025 16:52:30 -0400 Subject: [PATCH] UI: Support database static roles recovery (#9374) (#9444) * support read and recovery of database static roles * add and update tests * add changelog entry * add manual database input support and fix search * change dropdown alignment * update changelog entry * tidy * update changelog and api headers Co-authored-by: lane-wetmore --- changelog/_9225.txt | 2 +- .../recovery/page/snapshots/load.hbs | 3 + .../page/snapshots/snapshot-manage.hbs | 5 +- .../page/snapshots/snapshot-manage.ts | 69 ++++++++++++++++--- ui/app/resources/secrets/engine.ts | 2 +- ui/app/services/api.ts | 2 + ui/mirage/handlers/recovery.js | 41 ++++++----- .../recovery/snapshot-manage-test.js | 6 +- .../recovery/page/snapshot-manage-test.js | 49 ++++++++++--- ui/types/vault/api.d.ts | 12 ++++ 10 files changed, 151 insertions(+), 40 deletions(-) diff --git a/changelog/_9225.txt b/changelog/_9225.txt index 3b13321aa9..f2ee50fff0 100644 --- a/changelog/_9225.txt +++ b/changelog/_9225.txt @@ -1,3 +1,3 @@ ```release-note:feature -**UI Secrets Recovery (Enterprise)**: Adds ability to load and unload a snapshot from which single KVv1 or Cubbyhole secrets can be read or recovered to the cluster. +**UI Secrets Recovery (Enterprise)**: Adds ability to load and unload a snapshot from which single KVv1 or Cubbyhole secrets and Database static roles can be read or recovered to the cluster. ``` diff --git a/ui/app/components/recovery/page/snapshots/load.hbs b/ui/app/components/recovery/page/snapshots/load.hbs index bfb004ec63..b4696de7af 100644 --- a/ui/app/components/recovery/page/snapshots/load.hbs +++ b/ui/app/components/recovery/page/snapshots/load.hbs @@ -80,6 +80,9 @@ endpoint. {{F.options}} + {{#if this.configError}} + {{this.configError}} + {{/if}} {{else}} @@ -111,7 +113,7 @@ Mount Path - + {{#each this.recoverySupportedEngines as |engine|}} { + // TODO remove once endpoint is updated to accepted `read_snapshot_id` + @service declare readonly auth: AuthService; @service declare readonly api: ApiService; @service declare readonly currentCluster: any; @service declare readonly namespace: NamespaceService; @@ -43,7 +47,7 @@ export default class SnapshotManage extends Component { @tracked selectedMount?: MountOption; @tracked resourcePath = ''; - @tracked mountOptions: MountOption[] = []; + @tracked mountOptions: GroupedOption[] = []; @tracked secretData: SecretData | undefined; @tracked mountError = ''; @@ -59,6 +63,7 @@ export default class SnapshotManage extends Component { private pollingController: { start: () => Promise; cancel: () => void } | null = null; recoverySupportedEngines = [ + { display: 'Database', value: SupportedSecretBackendsEnum.DATABASE }, { display: 'Cubbyhole', value: SupportedSecretBackendsEnum.CUBBYHOLE }, { display: 'KV v1', value: SupportedSecretBackendsEnum.KV }, ]; @@ -142,7 +147,7 @@ export default class SnapshotManage extends Component { const headers = this.api.buildHeaders({ namespace }); const { secret } = await this.api.sys.internalUiListEnabledVisibleMounts(headers); - this.mountOptions = this.api.responseObjectToArray(secret, 'path').flatMap((engine) => { + const mounts = this.api.responseObjectToArray(secret, 'path').flatMap((engine) => { const eng = new SecretsEngineResource(engine); // performance secondaries cannot perform snapshot operations on shared paths @@ -159,6 +164,16 @@ export default class SnapshotManage extends Component { ] : []; }); + + const databases: MountOption[] = mounts.filter((m) => m.type === SupportedSecretBackendsEnum.DATABASE); + const secretEngines: MountOption[] = mounts.filter( + (m) => m.type !== SupportedSecretBackendsEnum.DATABASE + ); + + this.mountOptions = [ + ...(databases.length ? [{ groupName: 'Databases', options: databases }] : []), + { groupName: 'Secret Engines', options: secretEngines }, + ]; } catch { this.mountOptions = []; } @@ -218,6 +233,7 @@ export default class SnapshotManage extends Component { const mountPath = this.selectedMount?.path as string; const namespace = this.selectedNamespace === 'root' ? ROOT_NAMESPACE : this.selectedNamespace; const headers = this.api.buildHeaders({ namespace }); + switch (mountType) { case SupportedSecretBackendsEnum.KV: { const { data } = await this.api.secrets.kvV1Read( @@ -234,6 +250,25 @@ export default class SnapshotManage extends Component { this.secretData = data as SecretData; break; } + case SupportedSecretBackendsEnum.DATABASE: { + // TODO remove once endpoint is updated to accept `read_snapshot_id` + const { currentToken } = this.auth; + + const resp = await fetch( + `/v1/${mountPath}/static-roles/${this.resourcePath}?read_snapshot_id=${snapshot_id}`, + { + method: 'GET', + headers: { + 'X-Vault-Namespace': namespace, + 'X-Vault-Token': currentToken, + }, + } + ); + + const { data } = await resp.json(); + this.secretData = data as SecretData; + break; + } default: { // This should never be reached, but just in case throw new Error('Unsupported recovery engine'); @@ -258,18 +293,36 @@ export default class SnapshotManage extends Component { const { snapshot_id } = this.args.model.snapshot as { snapshot_id: string }; const mountType = this.selectedMount?.type; const mountPath = this.selectedMount?.path as string; + const namespace = this.selectedNamespace === 'root' ? ROOT_NAMESPACE : this.selectedNamespace; - const headers = this.api.buildHeaders({ namespace }); + const headers = this.api.buildHeaders({ namespace, recoverSnapshotId: snapshot_id }); + + // this query is used to build the recovered resource link in the success message + let query: { [key: string]: string } = {}; + if (namespace && namespace !== this.namespace.path) { + query = { namespace }; + } + + // Certain backends have a prefix which is needed for the recovery link we show to the user + let modelPrefix = ''; switch (mountType) { case SupportedSecretBackendsEnum.KV: { await this.api.secrets.kvV1Write(this.resourcePath, mountPath, {}, snapshot_id, undefined, headers); break; } case SupportedSecretBackendsEnum.CUBBYHOLE: { - this.api.buildHeaders({ namespace: namespace || this.namespace.path }); await this.api.secrets.cubbyholeWrite(this.resourcePath, {}, snapshot_id, undefined, headers); break; } + case SupportedSecretBackendsEnum.DATABASE: { + await this.api.secrets.databaseWriteStaticRole(this.resourcePath, mountPath, {}, headers); + modelPrefix = 'role/'; + query = { + ...query, + type: 'static', + }; + break; + } default: { // This should never be reached, but just in case throw new Error('Unsupported recovery engine'); @@ -277,11 +330,9 @@ export default class SnapshotManage extends Component { } this.recoveryData = { - models: [mountPath, this.resourcePath], + models: [mountPath, modelPrefix + this.resourcePath], + query, }; - if (namespace && namespace !== this.namespace.path) { - this.recoveryData.query = { namespace }; - } } catch (e) { const error = await this.api.parseError(e); this.bannerError = `Snapshot recovery error: ${error.message}`; diff --git a/ui/app/resources/secrets/engine.ts b/ui/app/resources/secrets/engine.ts index 4269a1e9e5..aeb8b1286f 100644 --- a/ui/app/resources/secrets/engine.ts +++ b/ui/app/resources/secrets/engine.ts @@ -16,9 +16,9 @@ import type { Mount } from 'vault/mount'; export const SUPPORTS_RECOVERY = [ SupportedSecretBackendsEnum.CUBBYHOLE, SupportedSecretBackendsEnum.KV, // only kv v1 + SupportedSecretBackendsEnum.DATABASE, ] as const; -// TODO: Add "database" when once that is supported later in 1.21 feature work export type RecoverySupportedEngines = (typeof SUPPORTS_RECOVERY)[number]; export default class SecretsEngineResource extends baseResourceFactory() { diff --git a/ui/app/services/api.ts b/ui/app/services/api.ts index cf963d622a..4b3bedd0f0 100644 --- a/ui/app/services/api.ts +++ b/ui/app/services/api.ts @@ -137,6 +137,8 @@ export default class ApiService extends Service { namespace: 'X-Vault-Namespace', token: 'X-Vault-Token', wrap: 'X-Vault-Wrap-TTL', + recoverSnapshotId: 'X-Vault-Recover-Snapshot-Id', + recoverSourcePath: 'X-Vault-Recover-Source-Path', }[key] as keyof XVaultHeaders; headers[headerKey] = headerMap[key as keyof HeaderMap]; diff --git a/ui/mirage/handlers/recovery.js b/ui/mirage/handlers/recovery.js index 1cc4f1f712..8063a34a6d 100644 --- a/ui/mirage/handlers/recovery.js +++ b/ui/mirage/handlers/recovery.js @@ -63,28 +63,18 @@ export default function (server) { secret: { 'cubbyhole/': { type: 'cubbyhole', - description: 'per-token private secret storage', local: true, - seal_wrap: false, - external_entropy_access: false, - config: { - default_lease_ttl: 0, - max_lease_ttl: 0, - force_no_cache: false, - }, + path: 'cubbyhole/', }, 'kv/': { type: 'kv', - description: 'key/value secret storage', - options: { version: '1' }, local: false, - seal_wrap: false, - external_entropy_access: false, - config: { - default_lease_ttl: 0, - max_lease_ttl: 0, - force_no_cache: false, - }, + path: 'kv/', + }, + 'database/': { + type: 'database', + local: true, + path: 'database/', }, }, }, @@ -139,11 +129,28 @@ export default function (server) { }; }); + server.get('/database/static-roles/:path', () => { + return { + data: { + credential_type: 'password', + db_name: 'test-db', + rotation_period: 86400, + rotation_statements: [], + skip_import_rotation: true, + username: 'super-user', + }, + }; + }); + // RECOVER SECRET HANDLERS server.post('/cubbyhole/:path', () => ({ data: {}, })); + server.post('/database/static-roles/:path', () => ({ + data: {}, + })); + // server.post('/kv/:path', () => ({ // data: {}, // })); diff --git a/ui/tests/acceptance/recovery/snapshot-manage-test.js b/ui/tests/acceptance/recovery/snapshot-manage-test.js index 17d681d786..723c840b7d 100644 --- a/ui/tests/acceptance/recovery/snapshot-manage-test.js +++ b/ui/tests/acceptance/recovery/snapshot-manage-test.js @@ -36,7 +36,7 @@ module('Acceptance | recovery | snapshot-manage', function (hooks) { await visit('/vault/recovery/snapshots'); await click(GENERAL.selectByAttr('mount')); - await click('[data-option-index]'); + await click('[data-option-index="1.0"]'); await fillIn(GENERAL.inputByAttr('resourcePath'), 'recovered-secret'); await click(GENERAL.button('recover')); @@ -57,7 +57,7 @@ module('Acceptance | recovery | snapshot-manage', function (hooks) { await click(GENERAL.selectByAttr('namespace')); await click('[data-option-index="1"]'); await click(GENERAL.selectByAttr('mount')); - await click('[data-option-index]'); + await click('[data-option-index="1.0"]'); await fillIn(GENERAL.inputByAttr('resourcePath'), 'recovered-secret'); await click(GENERAL.button('recover')); @@ -78,7 +78,7 @@ module('Acceptance | recovery | snapshot-manage', function (hooks) { await click(GENERAL.selectByAttr('namespace')); await click('[data-option-index="2"]'); await click(GENERAL.selectByAttr('mount')); - await click('[data-option-index]'); + await click('[data-option-index="1.0"]'); await fillIn(GENERAL.inputByAttr('resourcePath'), 'recovered-secret'); await click(GENERAL.button('recover')); diff --git a/ui/tests/integration/components/recovery/page/snapshot-manage-test.js b/ui/tests/integration/components/recovery/page/snapshot-manage-test.js index 1c33134a1a..a0c37a3fb4 100644 --- a/ui/tests/integration/components/recovery/page/snapshot-manage-test.js +++ b/ui/tests/integration/components/recovery/page/snapshot-manage-test.js @@ -5,7 +5,7 @@ import { module, test } from 'qunit'; import { setupRenderingTest } from 'vault/tests/helpers'; -import { click, fillIn, find, render } from '@ember/test-helpers'; +import { click, fillIn, find, render, waitFor } from '@ember/test-helpers'; import { hbs } from 'ember-cli-htmlbars'; import { setupMirage } from 'ember-cli-mirage/test-support'; @@ -78,19 +78,20 @@ module('Integration | Component | recovery/snapshots/snapshot-manage', function assert.strictEqual(nsSelect.textContent.trim(), 'root', 'namespace was reset'); const mountSelect = find(GENERAL.selectByAttr('mount')); - assert.strictEqual(mountSelect.textContent.trim(), '', 'mount is cleared'); + assert.strictEqual(mountSelect.textContent.trim(), 'Select a mount here', 'mount is cleared'); assert.dom(GENERAL.inputByAttr('resourcePath')).hasValue('', 'resource path is cleared'); }); - test('it performs read operation successfully in root namespace', async function (assert) { + test('it performs read operation successfully in root namespace - secret engine', async function (assert) { await render(hbs``); await click(GENERAL.selectByAttr('mount')); - await click('[data-option-index]'); + await click('[data-option-index="1.0"]'); await fillIn(GENERAL.inputByAttr('resourcePath'), 'my-path'); await click(GENERAL.button('read')); + await waitFor('[data-test-read-secrets]'); // Open modal assert.dom('[data-test-read-secrets]').exists('renders read modal'); @@ -101,13 +102,32 @@ module('Integration | Component | recovery/snapshots/snapshot-manage', function assert.dom('[data-test-read-secrets]').doesNotExist('read modal closed'); }); + test('it performs read operation successfully in root namespace - database', async function (assert) { + await render(hbs``); + + await click(GENERAL.selectByAttr('mount')); + await click('[data-option-index="0.0"]'); + await fillIn(GENERAL.inputByAttr('resourcePath'), 'test-static-role'); + + await click(GENERAL.button('read')); + await waitFor('[data-test-read-secrets]'); + + // Open modal + assert.dom('[data-test-read-secrets]').exists('renders read modal'); + assert.dom(GENERAL.infoRowLabel('db_name')).exists('renders role data'); + + // Close modal + await click(GENERAL.button('close')); + assert.dom('[data-test-read-secrets]').doesNotExist('read modal closed'); + }); + test('it performs read operation successfully for child namespace while in root context', async function (assert) { await render(hbs``); await click(GENERAL.selectByAttr('namespace')); await click('[data-option-index="1"]'); await click(GENERAL.selectByAttr('mount')); - await click('[data-option-index]'); + await click('[data-option-index="1.0"]'); await fillIn(GENERAL.inputByAttr('resourcePath'), 'my-path'); await click(GENERAL.button('read')); @@ -121,11 +141,11 @@ module('Integration | Component | recovery/snapshots/snapshot-manage', function assert.dom('[data-test-read-secrets]').doesNotExist('read modal closed'); }); - test('it performs recover operation successfully in root namespace', async function (assert) { + test('it performs recover operation successfully in root namespace - secret engine', async function (assert) { await render(hbs``); await click(GENERAL.selectByAttr('mount')); - await click('[data-option-index]'); + await click('[data-option-index="1.0"]'); await fillIn(GENERAL.inputByAttr('resourcePath'), 'recovered-secret'); await click(GENERAL.button('recover')); @@ -134,6 +154,18 @@ module('Integration | Component | recovery/snapshots/snapshot-manage', function assert.dom(GENERAL.inlineAlert).containsText('recovered-secret', 'shows the recovered path'); }); + test('it performs recover operation successfully in root namespace - database', async function (assert) { + await render(hbs``); + await click(GENERAL.selectByAttr('mount')); + await click('[data-option-index="0.0"]'); + await fillIn(GENERAL.inputByAttr('resourcePath'), 'test-static-role'); + + await click(GENERAL.button('recover')); + + assert.dom(GENERAL.inlineAlert).containsText('Success', 'shows success message'); + assert.dom(GENERAL.inlineAlert).containsText('test-static-role', 'shows the recovered path'); + }); + test('it performs recover operation successfully for child namespace while in root context', async function (assert) { await render(hbs``); @@ -154,7 +186,7 @@ module('Integration | Component | recovery/snapshots/snapshot-manage', function await fillIn(GENERAL.inputByAttr('resourcePath'), 'nonexistent-secret'); await click(GENERAL.selectByAttr('mount')); - await click('[data-option-index]'); + await click('[data-option-index="1.0"]'); await click(GENERAL.button('read')); assert.dom(GENERAL.inlineAlert).containsText('Error', 'shows error alert'); @@ -167,6 +199,7 @@ module('Integration | Component | recovery/snapshots/snapshot-manage', function await click(GENERAL.selectByAttr('mount')); await click('[data-option-index]'); await click(GENERAL.button('read')); + await waitFor('[data-test-read-secrets]'); assert.dom('[data-test-read-secrets]').exists('read modal opens'); diff --git a/ui/types/vault/api.d.ts b/ui/types/vault/api.d.ts index 1fb3171d65..38f85cb3d8 100644 --- a/ui/types/vault/api.d.ts +++ b/ui/types/vault/api.d.ts @@ -33,6 +33,12 @@ export type HeaderMap = } | { wrap: string; + } + | { + recoverSnapshotId: string; + } + | { + recoverSourcePath: string; }; export type XVaultHeaders = @@ -44,4 +50,10 @@ export type XVaultHeaders = } | { 'X-Vault-Wrap-TTL': string; + } + | { + 'X-Vault-Snapshot-Id': string; + } + | { + 'X-Vault-Recover-Source-Path': string; };