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