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 <lane.wetmore@hashicorp.com>
This commit is contained in:
Vault Automation 2025-09-18 16:52:30 -04:00 committed by GitHub
parent d1bad38f7f
commit 15ed6007d0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 151 additions and 40 deletions

View File

@ -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.
```

View File

@ -80,6 +80,9 @@
endpoint.
</F.HelperText>
<F.Options>{{F.options}}</F.Options>
{{#if this.configError}}
<F.Error data-test-validation-error="config">{{this.configError}}</F.Error>
{{/if}}
</Hds::Form::SuperSelect::Single::Field>
{{else}}
<Hds::Form::TextInput::Field

View File

@ -88,8 +88,10 @@
@selected={{this.selectedMount}}
@options={{this.mountOptions}}
@searchEnabled={{true}}
@searchField="path"
@isInvalid={{this.mountError}}
@selectedItemComponent={{component "recovery/snapshot-mount-selected-item"}}
@placeholder="Select a mount here"
data-test-select="mount"
as |F|
>
@ -111,7 +113,7 @@
<F.Label>Mount Path</F.Label>
<F.Control>
<Hds::SegmentedGroup as |SG|>
<SG.Dropdown as |D|>
<SG.Dropdown @listPosition="bottom-left" as |D|>
<D.ToggleButton @color="secondary" @text={{or this.selectedMount.type "Type"}} />
{{#each this.recoverySupportedEngines as |engine|}}
<D.Radio
@ -147,6 +149,7 @@
<Hds::Form::TextInput::Field
@value={{this.resourcePath}}
@isInvalid={{this.resourcePathError}}
placeholder="Enter the resource path..."
{{on "input" this.updateResourcePath}}
data-test-input="resourcePath"
as |F|

View File

@ -18,6 +18,7 @@ import {
} from 'vault/components/recovery/page/snapshots/snapshot-utils';
import type ApiService from 'vault/services/api';
import type AuthService from 'vault/vault/services/auth';
import type NamespaceService from 'vault/services/namespace';
import type { SnapshotManageModel } from 'vault/routes/vault/cluster/recovery/snapshots/snapshot/manage';
@ -29,12 +30,15 @@ type SecretData = { [key: string]: unknown };
type RecoveryData = {
models: string[];
query?: { namespace: string };
query?: { [key: string]: string };
};
type MountOption = { type: RecoverySupportedEngines; path: string };
type GroupedOption = { groupName: string; options: MountOption[] };
export default class SnapshotManage extends Component<Args> {
// 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<Args> {
@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<Args> {
private pollingController: { start: () => Promise<void>; 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<Args> {
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<Args> {
]
: [];
});
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<Args> {
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<Args> {
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<Args> {
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<Args> {
}
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}`;

View File

@ -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<Mount>() {

View File

@ -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];

View File

@ -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: {},
// }));

View File

@ -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'));

View File

@ -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`<Recovery::Page::Snapshots::SnapshotManage @model={{this.model}}/>`);
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`<Recovery::Page::Snapshots::SnapshotManage @model={{this.model}}/>`);
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`<Recovery::Page::Snapshots::SnapshotManage @model={{this.model}}/>`);
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`<Recovery::Page::Snapshots::SnapshotManage @model={{this.model}}/>`);
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`<Recovery::Page::Snapshots::SnapshotManage @model={{this.model}}/>`);
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`<Recovery::Page::Snapshots::SnapshotManage @model={{this.model}}/>`);
@ -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');

View File

@ -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;
};