Skip to content

Commit 15ed600

Browse files
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 <[email protected]>
1 parent d1bad38 commit 15ed600

File tree

10 files changed

+151
-40
lines changed

10 files changed

+151
-40
lines changed

changelog/_9225.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
```release-note:feature
2-
**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.
2+
**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.
33
```

ui/app/components/recovery/page/snapshots/load.hbs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,9 @@
8080
endpoint.
8181
</F.HelperText>
8282
<F.Options>{{F.options}}</F.Options>
83+
{{#if this.configError}}
84+
<F.Error data-test-validation-error="config">{{this.configError}}</F.Error>
85+
{{/if}}
8386
</Hds::Form::SuperSelect::Single::Field>
8487
{{else}}
8588
<Hds::Form::TextInput::Field

ui/app/components/recovery/page/snapshots/snapshot-manage.hbs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,8 +88,10 @@
8888
@selected={{this.selectedMount}}
8989
@options={{this.mountOptions}}
9090
@searchEnabled={{true}}
91+
@searchField="path"
9192
@isInvalid={{this.mountError}}
9293
@selectedItemComponent={{component "recovery/snapshot-mount-selected-item"}}
94+
@placeholder="Select a mount here"
9395
data-test-select="mount"
9496
as |F|
9597
>
@@ -111,7 +113,7 @@
111113
<F.Label>Mount Path</F.Label>
112114
<F.Control>
113115
<Hds::SegmentedGroup as |SG|>
114-
<SG.Dropdown as |D|>
116+
<SG.Dropdown @listPosition="bottom-left" as |D|>
115117
<D.ToggleButton @color="secondary" @text={{or this.selectedMount.type "Type"}} />
116118
{{#each this.recoverySupportedEngines as |engine|}}
117119
<D.Radio
@@ -147,6 +149,7 @@
147149
<Hds::Form::TextInput::Field
148150
@value={{this.resourcePath}}
149151
@isInvalid={{this.resourcePathError}}
152+
placeholder="Enter the resource path..."
150153
{{on "input" this.updateResourcePath}}
151154
data-test-input="resourcePath"
152155
as |F|

ui/app/components/recovery/page/snapshots/snapshot-manage.ts

Lines changed: 60 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import {
1818
} from 'vault/components/recovery/page/snapshots/snapshot-utils';
1919

2020
import type ApiService from 'vault/services/api';
21+
import type AuthService from 'vault/vault/services/auth';
2122
import type NamespaceService from 'vault/services/namespace';
2223
import type { SnapshotManageModel } from 'vault/routes/vault/cluster/recovery/snapshots/snapshot/manage';
2324

@@ -29,12 +30,15 @@ type SecretData = { [key: string]: unknown };
2930

3031
type RecoveryData = {
3132
models: string[];
32-
query?: { namespace: string };
33+
query?: { [key: string]: string };
3334
};
3435

3536
type MountOption = { type: RecoverySupportedEngines; path: string };
37+
type GroupedOption = { groupName: string; options: MountOption[] };
3638

3739
export default class SnapshotManage extends Component<Args> {
40+
// TODO remove once endpoint is updated to accepted `read_snapshot_id`
41+
@service declare readonly auth: AuthService;
3842
@service declare readonly api: ApiService;
3943
@service declare readonly currentCluster: any;
4044
@service declare readonly namespace: NamespaceService;
@@ -43,7 +47,7 @@ export default class SnapshotManage extends Component<Args> {
4347
@tracked selectedMount?: MountOption;
4448
@tracked resourcePath = '';
4549

46-
@tracked mountOptions: MountOption[] = [];
50+
@tracked mountOptions: GroupedOption[] = [];
4751
@tracked secretData: SecretData | undefined;
4852

4953
@tracked mountError = '';
@@ -59,6 +63,7 @@ export default class SnapshotManage extends Component<Args> {
5963
private pollingController: { start: () => Promise<void>; cancel: () => void } | null = null;
6064

6165
recoverySupportedEngines = [
66+
{ display: 'Database', value: SupportedSecretBackendsEnum.DATABASE },
6267
{ display: 'Cubbyhole', value: SupportedSecretBackendsEnum.CUBBYHOLE },
6368
{ display: 'KV v1', value: SupportedSecretBackendsEnum.KV },
6469
];
@@ -142,7 +147,7 @@ export default class SnapshotManage extends Component<Args> {
142147
const headers = this.api.buildHeaders({ namespace });
143148
const { secret } = await this.api.sys.internalUiListEnabledVisibleMounts(headers);
144149

145-
this.mountOptions = this.api.responseObjectToArray(secret, 'path').flatMap((engine) => {
150+
const mounts = this.api.responseObjectToArray(secret, 'path').flatMap((engine) => {
146151
const eng = new SecretsEngineResource(engine);
147152

148153
// performance secondaries cannot perform snapshot operations on shared paths
@@ -159,6 +164,16 @@ export default class SnapshotManage extends Component<Args> {
159164
]
160165
: [];
161166
});
167+
168+
const databases: MountOption[] = mounts.filter((m) => m.type === SupportedSecretBackendsEnum.DATABASE);
169+
const secretEngines: MountOption[] = mounts.filter(
170+
(m) => m.type !== SupportedSecretBackendsEnum.DATABASE
171+
);
172+
173+
this.mountOptions = [
174+
...(databases.length ? [{ groupName: 'Databases', options: databases }] : []),
175+
{ groupName: 'Secret Engines', options: secretEngines },
176+
];
162177
} catch {
163178
this.mountOptions = [];
164179
}
@@ -218,6 +233,7 @@ export default class SnapshotManage extends Component<Args> {
218233
const mountPath = this.selectedMount?.path as string;
219234
const namespace = this.selectedNamespace === 'root' ? ROOT_NAMESPACE : this.selectedNamespace;
220235
const headers = this.api.buildHeaders({ namespace });
236+
221237
switch (mountType) {
222238
case SupportedSecretBackendsEnum.KV: {
223239
const { data } = await this.api.secrets.kvV1Read(
@@ -234,6 +250,25 @@ export default class SnapshotManage extends Component<Args> {
234250
this.secretData = data as SecretData;
235251
break;
236252
}
253+
case SupportedSecretBackendsEnum.DATABASE: {
254+
// TODO remove once endpoint is updated to accept `read_snapshot_id`
255+
const { currentToken } = this.auth;
256+
257+
const resp = await fetch(
258+
`/v1/${mountPath}/static-roles/${this.resourcePath}?read_snapshot_id=${snapshot_id}`,
259+
{
260+
method: 'GET',
261+
headers: {
262+
'X-Vault-Namespace': namespace,
263+
'X-Vault-Token': currentToken,
264+
},
265+
}
266+
);
267+
268+
const { data } = await resp.json();
269+
this.secretData = data as SecretData;
270+
break;
271+
}
237272
default: {
238273
// This should never be reached, but just in case
239274
throw new Error('Unsupported recovery engine');
@@ -258,30 +293,46 @@ export default class SnapshotManage extends Component<Args> {
258293
const { snapshot_id } = this.args.model.snapshot as { snapshot_id: string };
259294
const mountType = this.selectedMount?.type;
260295
const mountPath = this.selectedMount?.path as string;
296+
261297
const namespace = this.selectedNamespace === 'root' ? ROOT_NAMESPACE : this.selectedNamespace;
262-
const headers = this.api.buildHeaders({ namespace });
298+
const headers = this.api.buildHeaders({ namespace, recoverSnapshotId: snapshot_id });
299+
300+
// this query is used to build the recovered resource link in the success message
301+
let query: { [key: string]: string } = {};
302+
if (namespace && namespace !== this.namespace.path) {
303+
query = { namespace };
304+
}
305+
306+
// Certain backends have a prefix which is needed for the recovery link we show to the user
307+
let modelPrefix = '';
263308
switch (mountType) {
264309
case SupportedSecretBackendsEnum.KV: {
265310
await this.api.secrets.kvV1Write(this.resourcePath, mountPath, {}, snapshot_id, undefined, headers);
266311
break;
267312
}
268313
case SupportedSecretBackendsEnum.CUBBYHOLE: {
269-
this.api.buildHeaders({ namespace: namespace || this.namespace.path });
270314
await this.api.secrets.cubbyholeWrite(this.resourcePath, {}, snapshot_id, undefined, headers);
271315
break;
272316
}
317+
case SupportedSecretBackendsEnum.DATABASE: {
318+
await this.api.secrets.databaseWriteStaticRole(this.resourcePath, mountPath, {}, headers);
319+
modelPrefix = 'role/';
320+
query = {
321+
...query,
322+
type: 'static',
323+
};
324+
break;
325+
}
273326
default: {
274327
// This should never be reached, but just in case
275328
throw new Error('Unsupported recovery engine');
276329
}
277330
}
278331

279332
this.recoveryData = {
280-
models: [mountPath, this.resourcePath],
333+
models: [mountPath, modelPrefix + this.resourcePath],
334+
query,
281335
};
282-
if (namespace && namespace !== this.namespace.path) {
283-
this.recoveryData.query = { namespace };
284-
}
285336
} catch (e) {
286337
const error = await this.api.parseError(e);
287338
this.bannerError = `Snapshot recovery error: ${error.message}`;

ui/app/resources/secrets/engine.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,9 @@ import type { Mount } from 'vault/mount';
1616
export const SUPPORTS_RECOVERY = [
1717
SupportedSecretBackendsEnum.CUBBYHOLE,
1818
SupportedSecretBackendsEnum.KV, // only kv v1
19+
SupportedSecretBackendsEnum.DATABASE,
1920
] as const;
2021

21-
// TODO: Add "database" when once that is supported later in 1.21 feature work
2222
export type RecoverySupportedEngines = (typeof SUPPORTS_RECOVERY)[number];
2323

2424
export default class SecretsEngineResource extends baseResourceFactory<Mount>() {

ui/app/services/api.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,8 @@ export default class ApiService extends Service {
137137
namespace: 'X-Vault-Namespace',
138138
token: 'X-Vault-Token',
139139
wrap: 'X-Vault-Wrap-TTL',
140+
recoverSnapshotId: 'X-Vault-Recover-Snapshot-Id',
141+
recoverSourcePath: 'X-Vault-Recover-Source-Path',
140142
}[key] as keyof XVaultHeaders;
141143

142144
headers[headerKey] = headerMap[key as keyof HeaderMap];

ui/mirage/handlers/recovery.js

Lines changed: 24 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -63,28 +63,18 @@ export default function (server) {
6363
secret: {
6464
'cubbyhole/': {
6565
type: 'cubbyhole',
66-
description: 'per-token private secret storage',
6766
local: true,
68-
seal_wrap: false,
69-
external_entropy_access: false,
70-
config: {
71-
default_lease_ttl: 0,
72-
max_lease_ttl: 0,
73-
force_no_cache: false,
74-
},
67+
path: 'cubbyhole/',
7568
},
7669
'kv/': {
7770
type: 'kv',
78-
description: 'key/value secret storage',
79-
options: { version: '1' },
8071
local: false,
81-
seal_wrap: false,
82-
external_entropy_access: false,
83-
config: {
84-
default_lease_ttl: 0,
85-
max_lease_ttl: 0,
86-
force_no_cache: false,
87-
},
72+
path: 'kv/',
73+
},
74+
'database/': {
75+
type: 'database',
76+
local: true,
77+
path: 'database/',
8878
},
8979
},
9080
},
@@ -139,11 +129,28 @@ export default function (server) {
139129
};
140130
});
141131

132+
server.get('/database/static-roles/:path', () => {
133+
return {
134+
data: {
135+
credential_type: 'password',
136+
db_name: 'test-db',
137+
rotation_period: 86400,
138+
rotation_statements: [],
139+
skip_import_rotation: true,
140+
username: 'super-user',
141+
},
142+
};
143+
});
144+
142145
// RECOVER SECRET HANDLERS
143146
server.post('/cubbyhole/:path', () => ({
144147
data: {},
145148
}));
146149

150+
server.post('/database/static-roles/:path', () => ({
151+
data: {},
152+
}));
153+
147154
// server.post('/kv/:path', () => ({
148155
// data: {},
149156
// }));

ui/tests/acceptance/recovery/snapshot-manage-test.js

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ module('Acceptance | recovery | snapshot-manage', function (hooks) {
3636
await visit('/vault/recovery/snapshots');
3737

3838
await click(GENERAL.selectByAttr('mount'));
39-
await click('[data-option-index]');
39+
await click('[data-option-index="1.0"]');
4040
await fillIn(GENERAL.inputByAttr('resourcePath'), 'recovered-secret');
4141

4242
await click(GENERAL.button('recover'));
@@ -57,7 +57,7 @@ module('Acceptance | recovery | snapshot-manage', function (hooks) {
5757
await click(GENERAL.selectByAttr('namespace'));
5858
await click('[data-option-index="1"]');
5959
await click(GENERAL.selectByAttr('mount'));
60-
await click('[data-option-index]');
60+
await click('[data-option-index="1.0"]');
6161
await fillIn(GENERAL.inputByAttr('resourcePath'), 'recovered-secret');
6262

6363
await click(GENERAL.button('recover'));
@@ -78,7 +78,7 @@ module('Acceptance | recovery | snapshot-manage', function (hooks) {
7878
await click(GENERAL.selectByAttr('namespace'));
7979
await click('[data-option-index="2"]');
8080
await click(GENERAL.selectByAttr('mount'));
81-
await click('[data-option-index]');
81+
await click('[data-option-index="1.0"]');
8282
await fillIn(GENERAL.inputByAttr('resourcePath'), 'recovered-secret');
8383

8484
await click(GENERAL.button('recover'));

0 commit comments

Comments
 (0)