Skip to content

Commit 7a2a0cb

Browse files
committed
support open-api secret recovery operations
1 parent 41d8301 commit 7a2a0cb

4 files changed

Lines changed: 150 additions & 31 deletions

File tree

sdk/framework/openapi.go

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -245,10 +245,12 @@ func documentPaths(backend *Backend, requestResponsePrefix string, doc *OASDocum
245245
func documentPath(p *Path, backend *Backend, requestResponsePrefix string, doc *OASDocument) error {
246246
var sudoPaths []string
247247
var unauthPaths []string
248+
var allowSnapshotReadPaths []string
248249

249250
if backend.PathsSpecial != nil {
250251
sudoPaths = backend.PathsSpecial.Root
251252
unauthPaths = backend.PathsSpecial.Unauthenticated
253+
allowSnapshotReadPaths = backend.PathsSpecial.AllowSnapshotRead
252254
}
253255

254256
// Convert optional parameters into distinct patterns to be processed independently.
@@ -278,6 +280,7 @@ func documentPath(p *Path, backend *Backend, requestResponsePrefix string, doc *
278280
pi.Sudo = specialPathMatch(path, sudoPaths)
279281
pi.Unauthenticated = specialPathMatch(path, unauthPaths)
280282
pi.DisplayAttrs = withoutOperationHints(p.DisplayAttrs)
283+
allowSnapshotRead := specialPathMatch(path, allowSnapshotReadPaths)
281284

282285
// If the newer style Operations map isn't defined, create one from the legacy fields.
283286
operations := p.Operations
@@ -329,6 +332,14 @@ func documentPath(p *Path, backend *Backend, requestResponsePrefix string, doc *
329332
continue
330333
}
331334

335+
// OpenAPI doesn't allow for multiple operations on the same path and with the same HTTP method, so both
336+
// Create, Update and Recover which operate on either POST or PUT methods are folded into Update (under POST
337+
// method on OpenAPI). Furthermore, so far there's no use case for a Recover operation on an endpoint that
338+
// doesn't support either Create or Update, so we skip it here as well.
339+
if opType == logical.RecoverOperation {
340+
continue
341+
}
342+
332343
if opType == logical.CreateOperation {
333344
pi.CreateSupported = true
334345

@@ -385,6 +396,17 @@ func documentPath(p *Path, backend *Backend, requestResponsePrefix string, doc *
385396
addFieldToOASSchema(s, name, field)
386397
}
387398

399+
// The recover operation is a special case, it's under the same POST/PUT method as Create and
400+
// Update, but it's triggered via a query parameter, not a field in the body.
401+
if operations[logical.RecoverOperation] != nil && opType != logical.PatchOperation {
402+
op.Parameters = append(op.Parameters, OASParameter{
403+
Name: "recover_snapshot_id",
404+
Description: "Triggers a recover operation using the given snapshot ID. Request body is ignored when a recover operation is requested.",
405+
In: "query",
406+
Schema: &OASSchema{Type: "string"},
407+
})
408+
}
409+
388410
// Make the ordering deterministic, so that the generated OpenAPI spec document, observed over several
389411
// versions, doesn't contain spurious non-semantic changes.
390412
sort.Strings(s.Required)
@@ -466,6 +488,15 @@ func documentPath(p *Path, backend *Backend, requestResponsePrefix string, doc *
466488
op.Parameters = append(op.Parameters, p)
467489
}
468490

491+
if allowSnapshotRead && opType != logical.DeleteOperation {
492+
op.Parameters = append(op.Parameters, OASParameter{
493+
Name: "read_snapshot_id",
494+
Description: "Targets the read operation to the provided loaded snapshot Id",
495+
In: "query",
496+
Schema: &OASSchema{Type: "string"},
497+
})
498+
}
499+
469500
// Sort parameters for a stable output
470501
sort.Slice(op.Parameters, func(i, j int) bool {
471502
return op.Parameters[i].Name < op.Parameters[j].Name

sdk/framework/openapi_test.go

Lines changed: 90 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import (
1010
"path/filepath"
1111
"reflect"
1212
"regexp"
13+
"slices"
1314
"sort"
1415
"strings"
1516
"testing"
@@ -246,18 +247,22 @@ func TestOpenAPI_SplitFields(t *testing.T) {
246247

247248
func TestOpenAPI_SpecialPaths(t *testing.T) {
248249
tests := map[string]struct {
249-
pattern string
250-
rootPaths []string
251-
rootExpected bool
252-
unauthenticatedPaths []string
253-
unauthenticatedExpected bool
250+
pattern string
251+
rootPaths []string
252+
rootExpected bool
253+
unauthenticatedPaths []string
254+
unauthenticatedExpected bool
255+
allowSnapshotRead []string
256+
allowSnapshotReadExpected bool
254257
}{
255258
"empty": {
256-
pattern: "foo",
257-
rootPaths: []string{},
258-
rootExpected: false,
259-
unauthenticatedPaths: []string{},
260-
unauthenticatedExpected: false,
259+
pattern: "foo",
260+
rootPaths: []string{},
261+
rootExpected: false,
262+
unauthenticatedPaths: []string{},
263+
unauthenticatedExpected: false,
264+
allowSnapshotRead: []string{},
265+
allowSnapshotReadExpected: false,
261266
},
262267
"exact-match-unauthenticated": {
263268
pattern: "foo",
@@ -336,17 +341,75 @@ func TestOpenAPI_SpecialPaths(t *testing.T) {
336341
unauthenticatedPaths: []string{"foo/bar"},
337342
unauthenticatedExpected: false,
338343
},
344+
"exact-match-snapshot-read": {
345+
pattern: "foo",
346+
rootPaths: []string{},
347+
rootExpected: false,
348+
unauthenticatedPaths: []string{},
349+
unauthenticatedExpected: false,
350+
allowSnapshotRead: []string{"foo"},
351+
allowSnapshotReadExpected: true,
352+
},
353+
"asterisk-match-snapshot-read": {
354+
pattern: "foo/bar",
355+
rootPaths: []string{},
356+
rootExpected: false,
357+
unauthenticatedPaths: []string{},
358+
unauthenticatedExpected: false,
359+
allowSnapshotRead: []string{"foo/*"},
360+
allowSnapshotReadExpected: true,
361+
},
362+
"no-match-snapshot-read": {
363+
pattern: "foo/bar",
364+
rootPaths: []string{},
365+
rootExpected: false,
366+
unauthenticatedPaths: []string{},
367+
unauthenticatedExpected: false,
368+
allowSnapshotRead: []string{"baz"},
369+
allowSnapshotReadExpected: false,
370+
},
371+
"multiple-snapshot-read-paths": {
372+
pattern: "foo/bar",
373+
rootPaths: []string{},
374+
rootExpected: false,
375+
unauthenticatedPaths: []string{},
376+
unauthenticatedExpected: false,
377+
allowSnapshotRead: []string{"foo/*", "baz"},
378+
allowSnapshotReadExpected: true,
379+
},
380+
"plus-match-snapshot-read": {
381+
pattern: "foo/bar/baz",
382+
rootPaths: []string{},
383+
rootExpected: false,
384+
unauthenticatedPaths: []string{},
385+
unauthenticatedExpected: false,
386+
allowSnapshotRead: []string{"foo/+/baz"},
387+
allowSnapshotReadExpected: true,
388+
},
389+
"plus-and-asterisk-snapshot-read": {
390+
pattern: "foo/bar/baz/something",
391+
rootPaths: []string{},
392+
rootExpected: false,
393+
unauthenticatedPaths: []string{},
394+
unauthenticatedExpected: false,
395+
allowSnapshotRead: []string{"foo/+/baz/*"},
396+
allowSnapshotReadExpected: true,
397+
},
339398
}
340399
for name, test := range tests {
341400
t.Run(name, func(t *testing.T) {
342401
doc := NewOASDocument("version")
343402
path := Path{
344403
Pattern: test.pattern,
404+
Operations: map[logical.Operation]OperationHandler{
405+
logical.ReadOperation: &PathOperation{},
406+
},
345407
}
346408
backend := &Backend{
347409
PathsSpecial: &logical.Paths{
348-
Root: test.rootPaths,
349-
Unauthenticated: test.unauthenticatedPaths,
410+
Root: test.rootPaths,
411+
Unauthenticated: test.unauthenticatedPaths,
412+
AllowSnapshotRead: test.allowSnapshotRead,
350413
},
351414
BackendType: logical.TypeLogical,
352415
}
@@ -364,6 +427,16 @@ func TestOpenAPI_SpecialPaths(t *testing.T) {
364427
if actual != test.unauthenticatedExpected {
365428
t.Fatalf("Test (unauth): expected: %v; got: %v", test.unauthenticatedExpected, actual)
366429
}
430+
431+
var supportsSnapshotId bool
432+
if doc.Paths["/"+test.pattern].Get != nil {
433+
supportsSnapshotId = slices.ContainsFunc(doc.Paths["/"+test.pattern].Get.Parameters, func(p OASParameter) bool {
434+
return p.Name == "read_snapshot_id"
435+
})
436+
}
437+
if supportsSnapshotId != test.allowSnapshotReadExpected {
438+
t.Fatalf("Test (allowSnapshotRead): expected: %v; got: %v", test.allowSnapshotReadExpected, actual)
439+
}
367440
})
368441
}
369442
}
@@ -475,14 +548,18 @@ func TestOpenAPI_Paths(t *testing.T) {
475548
Summary: "This shouldn't show up",
476549
Unpublished: true,
477550
},
551+
logical.RecoverOperation: &PathOperation{
552+
Summary: "Recover Summary shouldn't show up",
553+
},
478554
},
479555
DisplayAttrs: &DisplayAttributes{
480556
Navigation: true,
481557
},
482558
}
483559

484560
sp := &logical.Paths{
485-
Root: []string{"foo*"},
561+
Root: []string{"foo*"},
562+
AllowSnapshotRead: []string{"*"},
486563
}
487564
testPath(t, p, sp, expected("operations"))
488565
})
@@ -930,19 +1007,6 @@ func testPath(t *testing.T, path *Path, sp *logical.Paths, expectedJSON string)
9301007
}
9311008
}
9321009

933-
func getPathOp(pi *OASPathItem, op string) *OASOperation {
934-
switch op {
935-
case "get":
936-
return pi.Get
937-
case "post":
938-
return pi.Post
939-
case "delete":
940-
return pi.Delete
941-
default:
942-
panic("unexpected operation: " + op)
943-
}
944-
}
945-
9461010
func expected(name string) string {
9471011
data, err := ioutil.ReadFile(filepath.Join("testdata", name+".json"))
9481012
if err != nil {

sdk/framework/testdata/operations.json

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,14 @@
4343
"schema": {
4444
"type": "string"
4545
}
46+
},
47+
{
48+
"name": "read_snapshot_id",
49+
"description": "Targets the read operation to the provided loaded snapshot Id",
50+
"in": "query",
51+
"schema": {
52+
"type": "string"
53+
}
4654
}
4755
],
4856
"responses": {
@@ -58,6 +66,16 @@
5866
"tags": [
5967
"secrets"
6068
],
69+
"parameters": [
70+
{
71+
"name": "recover_snapshot_id",
72+
"description": "Triggers a recover operation using the given snapshot ID. Request body is ignored when a recover operation is requested.",
73+
"in": "query",
74+
"schema": {
75+
"type": "string"
76+
}
77+
}
78+
],
6179
"requestBody": {
6280
"required": true,
6381
"content": {
@@ -142,6 +160,14 @@
142160
]
143161
},
144162
"required": true
163+
},
164+
{
165+
"name": "read_snapshot_id",
166+
"description": "Targets the read operation to the provided loaded snapshot Id",
167+
"in": "query",
168+
"schema": {
169+
"type": "string"
170+
}
145171
}
146172
],
147173
"responses": {

vault/logical_cubbyhole.go

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,7 @@ func (b *CubbyholeBackend) paths() []*framework.Path {
7979
DisplayAttrs: &framework.DisplayAttributes{
8080
OperationVerb: "write",
8181
},
82-
Summary: "Store a secret at the specified location.",
82+
Summary: "Store a secret at the specified location, or (enterprise-only) recover it from given snapshot Id.",
8383
},
8484
logical.CreateOperation: &framework.PathOperation{
8585
Callback: b.handleWrite,
@@ -104,10 +104,8 @@ func (b *CubbyholeBackend) paths() []*framework.Path {
104104
},
105105
logical.RecoverOperation: &framework.PathOperation{
106106
Callback: b.handleWrite,
107-
DisplayAttrs: &framework.DisplayAttributes{
108-
OperationVerb: "recover",
109-
},
110-
Summary: "Recover a secret at the specified location.",
107+
// just like CreateOperation, recover is folded into update for OpenAPI documentation purposes, so
108+
// no operation verb or summary is set
111109
},
112110
},
113111

0 commit comments

Comments
 (0)