mirror of
https://github.com/hashicorp/vault.git
synced 2026-05-05 12:26:34 +02:00
* 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:
parent
d1bad38f7f
commit
15ed6007d0
@ -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.
|
||||
```
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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|
|
||||
|
||||
@ -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}`;
|
||||
|
||||
@ -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>() {
|
||||
|
||||
@ -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];
|
||||
|
||||
@ -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: {},
|
||||
// }));
|
||||
|
||||
@ -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'));
|
||||
|
||||
@ -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');
|
||||
|
||||
|
||||
12
ui/types/vault/api.d.ts
vendored
12
ui/types/vault/api.d.ts
vendored
@ -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;
|
||||
};
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user