mirror of
https://github.com/optim-enterprises-bv/vault.git
synced 2025-11-02 11:38:02 +00:00
Framework and API changes to support OpenAPI (#5546)
This commit is contained in:
@@ -14,7 +14,6 @@ import (
|
||||
|
||||
"github.com/hashicorp/errwrap"
|
||||
log "github.com/hashicorp/go-hclog"
|
||||
|
||||
"github.com/hashicorp/go-multierror"
|
||||
"github.com/hashicorp/vault/helper/errutil"
|
||||
"github.com/hashicorp/vault/helper/license"
|
||||
@@ -202,15 +201,22 @@ func (b *Backend) HandleRequest(ctx context.Context, req *logical.Request) (*log
|
||||
raw[k] = v
|
||||
}
|
||||
|
||||
// Look up the callback for this operation
|
||||
// Look up the callback for this operation, preferring the
|
||||
// path.Operations definition if present.
|
||||
var callback OperationFunc
|
||||
var ok bool
|
||||
if path.Callbacks != nil {
|
||||
callback, ok = path.Callbacks[req.Operation]
|
||||
|
||||
if path.Operations != nil {
|
||||
if op, ok := path.Operations[req.Operation]; ok {
|
||||
callback = op.Handler()
|
||||
}
|
||||
} else {
|
||||
callback = path.Callbacks[req.Operation]
|
||||
}
|
||||
ok := callback != nil
|
||||
|
||||
if !ok {
|
||||
if req.Operation == logical.HelpOperation {
|
||||
callback = path.helpCallback()
|
||||
callback = path.helpCallback(b)
|
||||
ok = true
|
||||
}
|
||||
}
|
||||
@@ -229,7 +235,6 @@ func (b *Backend) HandleRequest(ctx context.Context, req *logical.Request) (*log
|
||||
}
|
||||
}
|
||||
|
||||
// Call the callback with the request and the data
|
||||
return callback(ctx, req, &fd)
|
||||
}
|
||||
|
||||
@@ -370,7 +375,13 @@ func (b *Backend) handleRootHelp() (*logical.Response, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return logical.HelpResponse(help, nil), nil
|
||||
// Build OpenAPI response for the entire backend
|
||||
doc := NewOASDocument()
|
||||
if err := documentPaths(b, doc); err != nil {
|
||||
b.Logger().Warn("error generating OpenAPI", "error", err)
|
||||
}
|
||||
|
||||
return logical.HelpResponse(help, nil, doc), nil
|
||||
}
|
||||
|
||||
func (b *Backend) handleRevokeRenew(ctx context.Context, req *logical.Request) (*logical.Response, error) {
|
||||
@@ -492,6 +503,8 @@ type FieldSchema struct {
|
||||
Type FieldType
|
||||
Default interface{}
|
||||
Description string
|
||||
Required bool
|
||||
Deprecated bool
|
||||
}
|
||||
|
||||
// DefaultOrZero returns the default value if it is set, or otherwise
|
||||
|
||||
@@ -2,13 +2,13 @@ package framework
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"reflect"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"net/http"
|
||||
|
||||
"github.com/hashicorp/vault/logical"
|
||||
)
|
||||
|
||||
@@ -52,10 +52,17 @@ func TestBackendHandleRequest(t *testing.T) {
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
handler := func(ctx context.Context, req *logical.Request, data *FieldData) (*logical.Response, error) {
|
||||
return &logical.Response{
|
||||
Data: map[string]interface{}{
|
||||
"amount": data.Get("amount"),
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
b := &Backend{
|
||||
Paths: []*Path{
|
||||
&Path{
|
||||
{
|
||||
Pattern: "foo/bar",
|
||||
Fields: map[string]*FieldSchema{
|
||||
"value": &FieldSchema{Type: TypeInt},
|
||||
@@ -64,21 +71,48 @@ func TestBackendHandleRequest(t *testing.T) {
|
||||
logical.ReadOperation: callback,
|
||||
},
|
||||
},
|
||||
{
|
||||
Pattern: "foo/baz/handler",
|
||||
Fields: map[string]*FieldSchema{
|
||||
"amount": &FieldSchema{Type: TypeInt},
|
||||
},
|
||||
Operations: map[logical.Operation]OperationHandler{
|
||||
logical.ReadOperation: &PathOperation{Callback: handler},
|
||||
},
|
||||
},
|
||||
{
|
||||
Pattern: "foo/both/handler",
|
||||
Fields: map[string]*FieldSchema{
|
||||
"amount": &FieldSchema{Type: TypeInt},
|
||||
},
|
||||
Callbacks: map[logical.Operation]OperationFunc{
|
||||
logical.ReadOperation: callback,
|
||||
},
|
||||
Operations: map[logical.Operation]OperationHandler{
|
||||
logical.ReadOperation: &PathOperation{Callback: handler},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, path := range []string{"foo/bar", "foo/baz/handler", "foo/both/handler"} {
|
||||
key := "value"
|
||||
if strings.Contains(path, "handler") {
|
||||
key = "amount"
|
||||
}
|
||||
resp, err := b.HandleRequest(context.Background(), &logical.Request{
|
||||
Operation: logical.ReadOperation,
|
||||
Path: "foo/bar",
|
||||
Data: map[string]interface{}{"value": "42"},
|
||||
Path: path,
|
||||
Data: map[string]interface{}{key: "42"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("err: %s", err)
|
||||
}
|
||||
if resp.Data["value"] != 42 {
|
||||
if resp.Data[key] != 42 {
|
||||
t.Fatalf("bad: %#v", resp)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackendHandleRequest_badwrite(t *testing.T) {
|
||||
callback := func(ctx context.Context, req *logical.Request, data *FieldData) (*logical.Response, error) {
|
||||
|
||||
607
logical/framework/openapi.go
Normal file
607
logical/framework/openapi.go
Normal file
@@ -0,0 +1,607 @@
|
||||
package framework
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"reflect"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
log "github.com/hashicorp/go-hclog"
|
||||
"github.com/hashicorp/vault/helper/wrapping"
|
||||
"github.com/hashicorp/vault/logical"
|
||||
"github.com/hashicorp/vault/version"
|
||||
"github.com/mitchellh/mapstructure"
|
||||
)
|
||||
|
||||
// OpenAPI specification (OAS): https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.2.md
|
||||
const OASVersion = "3.0.2"
|
||||
|
||||
// NewOASDocument returns an empty OpenAPI document.
|
||||
func NewOASDocument() *OASDocument {
|
||||
return &OASDocument{
|
||||
Version: OASVersion,
|
||||
Info: OASInfo{
|
||||
Title: "HashiCorp Vault API",
|
||||
Description: "HTTP API that gives you full access to Vault. All API routes are prefixed with `/v1/`.",
|
||||
Version: version.GetVersion().Version,
|
||||
License: OASLicense{
|
||||
Name: "Mozilla Public License 2.0",
|
||||
URL: "https://www.mozilla.org/en-US/MPL/2.0",
|
||||
},
|
||||
},
|
||||
Paths: make(map[string]*OASPathItem),
|
||||
}
|
||||
}
|
||||
|
||||
// NewOASDocumentFromMap builds an OASDocument from an existing map version of a document.
|
||||
// If a document has been decoded from JSON or received from a plugin, it will be as a map[string]interface{}
|
||||
// and needs special handling beyond the default mapstructure decoding.
|
||||
func NewOASDocumentFromMap(input map[string]interface{}) (*OASDocument, error) {
|
||||
|
||||
// The Responses map uses integer keys (the response code), but once translated into JSON
|
||||
// (e.g. during the plugin transport) these become strings. mapstructure will not coerce these back
|
||||
// to integers without a custom decode hook.
|
||||
decodeHook := func(src reflect.Type, tgt reflect.Type, inputRaw interface{}) (interface{}, error) {
|
||||
|
||||
// Only alter data if:
|
||||
// 1. going from string to int
|
||||
// 2. string represent an int in status code range (100-599)
|
||||
if src.Kind() == reflect.String && tgt.Kind() == reflect.Int {
|
||||
if input, ok := inputRaw.(string); ok {
|
||||
if intval, err := strconv.Atoi(input); err == nil {
|
||||
if intval >= 100 && intval < 600 {
|
||||
return intval, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return inputRaw, nil
|
||||
}
|
||||
|
||||
doc := new(OASDocument)
|
||||
|
||||
config := &mapstructure.DecoderConfig{
|
||||
DecodeHook: decodeHook,
|
||||
Result: doc,
|
||||
}
|
||||
|
||||
decoder, err := mapstructure.NewDecoder(config)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := decoder.Decode(input); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return doc, nil
|
||||
}
|
||||
|
||||
type OASDocument struct {
|
||||
Version string `json:"openapi" mapstructure:"openapi"`
|
||||
Info OASInfo `json:"info"`
|
||||
Paths map[string]*OASPathItem `json:"paths"`
|
||||
}
|
||||
|
||||
type OASInfo struct {
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
Version string `json:"version"`
|
||||
License OASLicense `json:"license"`
|
||||
}
|
||||
|
||||
type OASLicense struct {
|
||||
Name string `json:"name"`
|
||||
URL string `json:"url"`
|
||||
}
|
||||
|
||||
type OASPathItem struct {
|
||||
Description string `json:"description,omitempty"`
|
||||
Parameters []OASParameter `json:"parameters,omitempty"`
|
||||
Sudo bool `json:"x-vault-sudo,omitempty" mapstructure:"x-vault-sudo"`
|
||||
Unauthenticated bool `json:"x-vault-unauthenticated,omitempty" mapstructure:"x-vault-unauthenticated"`
|
||||
CreateSupported bool `json:"x-vault-create-supported,omitempty" mapstructure:"x-vault-create-supported"`
|
||||
|
||||
Get *OASOperation `json:"get,omitempty"`
|
||||
Post *OASOperation `json:"post,omitempty"`
|
||||
Delete *OASOperation `json:"delete,omitempty"`
|
||||
}
|
||||
|
||||
// NewOASOperation creates an empty OpenAPI Operations object.
|
||||
func NewOASOperation() *OASOperation {
|
||||
return &OASOperation{
|
||||
Responses: make(map[int]*OASResponse),
|
||||
}
|
||||
}
|
||||
|
||||
type OASOperation struct {
|
||||
Summary string `json:"summary,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Tags []string `json:"tags,omitempty"`
|
||||
Parameters []OASParameter `json:"parameters,omitempty"`
|
||||
RequestBody *OASRequestBody `json:"requestBody,omitempty"`
|
||||
Responses map[int]*OASResponse `json:"responses"`
|
||||
Deprecated bool `json:"deprecated,omitempty"`
|
||||
}
|
||||
|
||||
type OASParameter struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description,omitempty"`
|
||||
In string `json:"in"`
|
||||
Schema *OASSchema `json:"schema,omitempty"`
|
||||
Required bool `json:"required,omitempty"`
|
||||
Deprecated bool `json:"deprecated,omitempty"`
|
||||
}
|
||||
|
||||
type OASRequestBody struct {
|
||||
Description string `json:"description,omitempty"`
|
||||
Content OASContent `json:"content,omitempty"`
|
||||
}
|
||||
|
||||
type OASContent map[string]*OASMediaTypeObject
|
||||
|
||||
type OASMediaTypeObject struct {
|
||||
Schema *OASSchema `json:"schema,omitempty"`
|
||||
}
|
||||
|
||||
type OASSchema struct {
|
||||
Type string `json:"type,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Properties map[string]*OASSchema `json:"properties,omitempty"`
|
||||
Items *OASSchema `json:"items,omitempty"`
|
||||
Format string `json:"format,omitempty"`
|
||||
Pattern string `json:"pattern,omitempty"`
|
||||
Example interface{} `json:"example,omitempty"`
|
||||
Deprecated bool `json:"deprecated,omitempty"`
|
||||
}
|
||||
|
||||
type OASResponse struct {
|
||||
Description string `json:"description"`
|
||||
Content OASContent `json:"content,omitempty"`
|
||||
}
|
||||
|
||||
var OASStdRespOK = &OASResponse{
|
||||
Description: "OK",
|
||||
}
|
||||
|
||||
var OASStdRespNoContent = &OASResponse{
|
||||
Description: "empty body",
|
||||
}
|
||||
|
||||
// Regex for handling optional and named parameters in paths, and string cleanup.
|
||||
// Predefined here to avoid substantial recompilation.
|
||||
|
||||
// Capture optional path elements in ungreedy (?U) fashion
|
||||
// Both "(leases/)?renew" and "(/(?P<name>.+))?" formats are detected
|
||||
var optRe = regexp.MustCompile(`(?U)\([^(]*\)\?|\(/\(\?P<[^(]*\)\)\?`)
|
||||
|
||||
var reqdRe = regexp.MustCompile(`\(?\?P<(\w+)>[^)]*\)?`) // Capture required parameters, e.g. "(?P<name>regex)"
|
||||
var altRe = regexp.MustCompile(`\((.*)\|(.*)\)`) // Capture alternation elements, e.g. "(raw/?$|raw/(?P<path>.+))"
|
||||
var pathFieldsRe = regexp.MustCompile(`{(\w+)}`) // Capture OpenAPI-style named parameters, e.g. "lookup/{urltoken}",
|
||||
var cleanCharsRe = regexp.MustCompile("[()^$?]") // Set of regex characters that will be stripped during cleaning
|
||||
var cleanSuffixRe = regexp.MustCompile(`/\?\$?$`) // Path suffix patterns that will be stripped during cleaning
|
||||
var wsRe = regexp.MustCompile(`\s+`) // Match whitespace, to be compressed during cleaning
|
||||
|
||||
// documentPaths parses all paths in a framework.Backend into OpenAPI paths.
|
||||
func documentPaths(backend *Backend, doc *OASDocument) error {
|
||||
for _, p := range backend.Paths {
|
||||
if err := documentPath(p, backend.SpecialPaths(), backend.BackendType, doc); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// documentPath parses a framework.Path into one or more OpenAPI paths.
|
||||
func documentPath(p *Path, specialPaths *logical.Paths, backendType logical.BackendType, doc *OASDocument) error {
|
||||
var sudoPaths []string
|
||||
var unauthPaths []string
|
||||
|
||||
if specialPaths != nil {
|
||||
sudoPaths = specialPaths.Root
|
||||
unauthPaths = specialPaths.Unauthenticated
|
||||
}
|
||||
|
||||
// Convert optional parameters into distinct patterns to be process independently.
|
||||
paths := expandPattern(p.Pattern)
|
||||
|
||||
for _, path := range paths {
|
||||
// Construct a top level PathItem which will be populated as the path is processed.
|
||||
pi := OASPathItem{
|
||||
Description: cleanString(p.HelpSynopsis),
|
||||
}
|
||||
|
||||
pi.Sudo = specialPathMatch(path, sudoPaths)
|
||||
pi.Unauthenticated = specialPathMatch(path, unauthPaths)
|
||||
|
||||
// If the newer style Operations map isn't defined, create one from the legacy fields.
|
||||
operations := p.Operations
|
||||
if operations == nil {
|
||||
operations = make(map[logical.Operation]OperationHandler)
|
||||
|
||||
for opType, cb := range p.Callbacks {
|
||||
operations[opType] = &PathOperation{
|
||||
Callback: cb,
|
||||
Summary: p.HelpSynopsis,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Process path and header parameters, which are common to all operations.
|
||||
// Body fields will be added to individual operations.
|
||||
pathFields, bodyFields := splitFields(p.Fields, path)
|
||||
|
||||
for name, field := range pathFields {
|
||||
location := "path"
|
||||
required := true
|
||||
|
||||
// Header parameters are part of the Parameters group but with
|
||||
// a dedicated "header" location, a header parameter is not required.
|
||||
if field.Type == TypeHeader {
|
||||
location = "header"
|
||||
required = false
|
||||
}
|
||||
|
||||
t := convertType(field.Type)
|
||||
p := OASParameter{
|
||||
Name: name,
|
||||
Description: cleanString(field.Description),
|
||||
In: location,
|
||||
Schema: &OASSchema{
|
||||
Type: t.baseType,
|
||||
Pattern: t.pattern,
|
||||
},
|
||||
Required: required,
|
||||
Deprecated: field.Deprecated,
|
||||
}
|
||||
pi.Parameters = append(pi.Parameters, p)
|
||||
}
|
||||
|
||||
// Sort parameters for a stable output
|
||||
sort.Slice(pi.Parameters, func(i, j int) bool {
|
||||
return strings.ToLower(pi.Parameters[i].Name) < strings.ToLower(pi.Parameters[j].Name)
|
||||
})
|
||||
|
||||
// Process each supported operation by building up an Operation object
|
||||
// with descriptions, properties and examples from the framework.Path data.
|
||||
for opType, opHandler := range operations {
|
||||
props := opHandler.Properties()
|
||||
if props.Unpublished {
|
||||
continue
|
||||
}
|
||||
|
||||
if opType == logical.CreateOperation {
|
||||
pi.CreateSupported = true
|
||||
|
||||
// If both Create and Update are defined, only process Update.
|
||||
if operations[logical.UpdateOperation] != nil {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// If both List and Read are defined, only process Read.
|
||||
if opType == logical.ListOperation && operations[logical.ReadOperation] != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
op := NewOASOperation()
|
||||
|
||||
op.Summary = props.Summary
|
||||
op.Description = props.Description
|
||||
op.Deprecated = props.Deprecated
|
||||
|
||||
// Add any fields not present in the path as body parameters for POST.
|
||||
if opType == logical.CreateOperation || opType == logical.UpdateOperation {
|
||||
s := &OASSchema{
|
||||
Type: "object",
|
||||
Properties: make(map[string]*OASSchema),
|
||||
}
|
||||
|
||||
for name, field := range bodyFields {
|
||||
openapiField := convertType(field.Type)
|
||||
p := OASSchema{
|
||||
Type: openapiField.baseType,
|
||||
Description: cleanString(field.Description),
|
||||
Format: openapiField.format,
|
||||
Pattern: openapiField.pattern,
|
||||
Deprecated: field.Deprecated,
|
||||
}
|
||||
if openapiField.baseType == "array" {
|
||||
p.Items = &OASSchema{
|
||||
Type: openapiField.items,
|
||||
}
|
||||
}
|
||||
s.Properties[name] = &p
|
||||
}
|
||||
|
||||
// If examples were given, use the first one as the sample
|
||||
// of this schema.
|
||||
if len(props.Examples) > 0 {
|
||||
s.Example = props.Examples[0].Data
|
||||
}
|
||||
|
||||
// Set the final request body. Only JSON request data is supported.
|
||||
if len(s.Properties) > 0 || s.Example != nil {
|
||||
op.RequestBody = &OASRequestBody{
|
||||
Content: OASContent{
|
||||
"application/json": &OASMediaTypeObject{
|
||||
Schema: s,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// LIST is represented as GET with a `list` query parameter
|
||||
if opType == logical.ListOperation || (opType == logical.ReadOperation && operations[logical.ListOperation] != nil) {
|
||||
op.Parameters = append(op.Parameters, OASParameter{
|
||||
Name: "list",
|
||||
Description: "Return a list if `true`",
|
||||
In: "query",
|
||||
Schema: &OASSchema{Type: "string"},
|
||||
})
|
||||
}
|
||||
|
||||
// Add tags based on backend type
|
||||
var tags []string
|
||||
switch backendType {
|
||||
case logical.TypeLogical:
|
||||
tags = []string{"secrets"}
|
||||
case logical.TypeCredential:
|
||||
tags = []string{"auth"}
|
||||
}
|
||||
|
||||
op.Tags = append(op.Tags, tags...)
|
||||
|
||||
// Set default responses.
|
||||
if len(props.Responses) == 0 {
|
||||
if opType == logical.DeleteOperation {
|
||||
op.Responses[204] = OASStdRespNoContent
|
||||
} else {
|
||||
op.Responses[200] = OASStdRespOK
|
||||
}
|
||||
}
|
||||
|
||||
// Add any defined response details.
|
||||
for code, responses := range props.Responses {
|
||||
var description string
|
||||
content := make(OASContent)
|
||||
|
||||
for i, resp := range responses {
|
||||
if i == 0 {
|
||||
description = resp.Description
|
||||
}
|
||||
if resp.Example != nil {
|
||||
mediaType := resp.MediaType
|
||||
if mediaType == "" {
|
||||
mediaType = "application/json"
|
||||
}
|
||||
|
||||
// create a version of the response that will not emit null items
|
||||
cr, err := cleanResponse(resp.Example)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Only one example per media type is allowed, so first one wins
|
||||
if _, ok := content[mediaType]; !ok {
|
||||
content[mediaType] = &OASMediaTypeObject{
|
||||
Schema: &OASSchema{
|
||||
Example: cr,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
op.Responses[code] = &OASResponse{
|
||||
Description: description,
|
||||
Content: content,
|
||||
}
|
||||
}
|
||||
|
||||
switch opType {
|
||||
case logical.CreateOperation, logical.UpdateOperation:
|
||||
pi.Post = op
|
||||
case logical.ReadOperation, logical.ListOperation:
|
||||
pi.Get = op
|
||||
case logical.DeleteOperation:
|
||||
pi.Delete = op
|
||||
}
|
||||
}
|
||||
|
||||
doc.Paths["/"+path] = &pi
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func specialPathMatch(path string, specialPaths []string) bool {
|
||||
// Test for exact or prefix match of special paths.
|
||||
for _, sp := range specialPaths {
|
||||
if sp == path ||
|
||||
(strings.HasSuffix(sp, "*") && strings.HasPrefix(path, sp[0:len(sp)-1])) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// expandPattern expands a regex pattern by generating permutations of any optional parameters
|
||||
// and changing named parameters into their {openapi} equivalents.
|
||||
func expandPattern(pattern string) []string {
|
||||
var paths []string
|
||||
|
||||
// GenericNameRegex adds a regex that complicates our parsing. It is much easier to
|
||||
// detect and remove it now than to compensate for in the other regexes.
|
||||
//
|
||||
// example: (?P<foo>\\w(([\\w-.]+)?\\w)?) -> (?P<foo>)
|
||||
base := GenericNameRegex("")
|
||||
start := strings.Index(base, ">")
|
||||
end := strings.LastIndex(base, ")")
|
||||
regexToRemove := ""
|
||||
if start != -1 && end != -1 && end > start {
|
||||
regexToRemove = base[start+1 : end]
|
||||
}
|
||||
|
||||
pattern = strings.Replace(pattern, regexToRemove, "", -1)
|
||||
|
||||
// Initialize paths with the original pattern or the halves of an
|
||||
// alternation, which is also present in some patterns.
|
||||
matches := altRe.FindAllStringSubmatch(pattern, -1)
|
||||
if len(matches) > 0 {
|
||||
paths = []string{matches[0][1], matches[0][2]}
|
||||
} else {
|
||||
paths = []string{pattern}
|
||||
}
|
||||
|
||||
// Expand all optional regex elements into two paths. This approach is really only useful up to 2 optional
|
||||
// groups, but we probably don't want to deal with the exponential increase beyond that anyway.
|
||||
for i := 0; i < len(paths); i++ {
|
||||
p := paths[i]
|
||||
|
||||
// match is a 2-element slice that will have a start and end index
|
||||
// for the left-most match of a regex of form: (lease/)?
|
||||
match := optRe.FindStringIndex(p)
|
||||
|
||||
if match != nil {
|
||||
// create a path that includes the optional element but without
|
||||
// parenthesis or the '?' character.
|
||||
paths[i] = p[:match[0]] + p[match[0]+1:match[1]-2] + p[match[1]:]
|
||||
|
||||
// create a path that excludes the optional element.
|
||||
paths = append(paths, p[:match[0]]+p[match[1]:])
|
||||
i--
|
||||
}
|
||||
}
|
||||
|
||||
// Replace named parameters (?P<foo>) with {foo}
|
||||
var replacedPaths []string
|
||||
|
||||
for _, path := range paths {
|
||||
result := reqdRe.FindAllStringSubmatch(path, -1)
|
||||
if result != nil {
|
||||
for _, p := range result {
|
||||
par := p[1]
|
||||
path = strings.Replace(path, p[0], fmt.Sprintf("{%s}", par), 1)
|
||||
}
|
||||
}
|
||||
// Final cleanup
|
||||
path = cleanSuffixRe.ReplaceAllString(path, "")
|
||||
path = cleanCharsRe.ReplaceAllString(path, "")
|
||||
replacedPaths = append(replacedPaths, path)
|
||||
}
|
||||
|
||||
return replacedPaths
|
||||
}
|
||||
|
||||
// schemaType is a subset of the JSON Schema elements used as a target
|
||||
// for conversions from Vault's standard FieldTypes.
|
||||
type schemaType struct {
|
||||
baseType string
|
||||
items string
|
||||
format string
|
||||
pattern string
|
||||
}
|
||||
|
||||
// convertType translates a FieldType into an OpenAPI type.
|
||||
// In the case of arrays, a subtype is returned as well.
|
||||
func convertType(t FieldType) schemaType {
|
||||
ret := schemaType{}
|
||||
|
||||
switch t {
|
||||
case TypeString, TypeHeader:
|
||||
ret.baseType = "string"
|
||||
case TypeNameString:
|
||||
ret.baseType = "string"
|
||||
ret.pattern = `\w([\w-.]*\w)?`
|
||||
case TypeLowerCaseString:
|
||||
ret.baseType = "string"
|
||||
ret.format = "lowercase"
|
||||
case TypeInt:
|
||||
ret.baseType = "number"
|
||||
case TypeDurationSecond:
|
||||
ret.baseType = "number"
|
||||
ret.format = "seconds"
|
||||
case TypeBool:
|
||||
ret.baseType = "boolean"
|
||||
case TypeMap:
|
||||
ret.baseType = "object"
|
||||
ret.format = "map"
|
||||
case TypeKVPairs:
|
||||
ret.baseType = "object"
|
||||
ret.format = "kvpairs"
|
||||
case TypeSlice:
|
||||
ret.baseType = "array"
|
||||
ret.items = "object"
|
||||
case TypeStringSlice, TypeCommaStringSlice:
|
||||
ret.baseType = "array"
|
||||
ret.items = "string"
|
||||
case TypeCommaIntSlice:
|
||||
ret.baseType = "array"
|
||||
ret.items = "number"
|
||||
default:
|
||||
log.L().Warn("error parsing field type", "type", t)
|
||||
ret.format = "unknown"
|
||||
}
|
||||
|
||||
return ret
|
||||
}
|
||||
|
||||
// cleanString prepares s for inclusion in the output
|
||||
func cleanString(s string) string {
|
||||
// clean leading/trailing whitespace, and replace whitespace runs into a single space
|
||||
s = strings.TrimSpace(s)
|
||||
s = wsRe.ReplaceAllString(s, " ")
|
||||
return s
|
||||
}
|
||||
|
||||
// splitFields partitions fields into path and body groups
|
||||
// The input pattern is expected to have been run through expandPattern,
|
||||
// with paths parameters denotes in {braces}.
|
||||
func splitFields(allFields map[string]*FieldSchema, pattern string) (pathFields, bodyFields map[string]*FieldSchema) {
|
||||
pathFields = make(map[string]*FieldSchema)
|
||||
bodyFields = make(map[string]*FieldSchema)
|
||||
|
||||
for _, match := range pathFieldsRe.FindAllStringSubmatch(pattern, -1) {
|
||||
name := match[1]
|
||||
pathFields[name] = allFields[name]
|
||||
}
|
||||
|
||||
for name, field := range allFields {
|
||||
if _, ok := pathFields[name]; !ok {
|
||||
// Header fields are in "parameters" with other path fields
|
||||
if field.Type == TypeHeader {
|
||||
pathFields[name] = field
|
||||
} else {
|
||||
bodyFields[name] = field
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return pathFields, bodyFields
|
||||
}
|
||||
|
||||
// cleanedResponse is identical to logical.Response but with nulls
|
||||
// removed from from JSON encoding
|
||||
type cleanedResponse struct {
|
||||
Secret *logical.Secret `json:"secret,omitempty"`
|
||||
Auth *logical.Auth `json:"auth,omitempty"`
|
||||
Data map[string]interface{} `json:"data,omitempty"`
|
||||
Redirect string `json:"redirect,omitempty"`
|
||||
Warnings []string `json:"warnings,omitempty"`
|
||||
WrapInfo *wrapping.ResponseWrapInfo `json:"wrap_info,omitempty"`
|
||||
}
|
||||
|
||||
func cleanResponse(resp *logical.Response) (*cleanedResponse, error) {
|
||||
var r cleanedResponse
|
||||
|
||||
if err := mapstructure.Decode(resp, &r); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &r, nil
|
||||
}
|
||||
468
logical/framework/openapi_test.go
Normal file
468
logical/framework/openapi_test.go
Normal file
@@ -0,0 +1,468 @@
|
||||
package framework
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io/ioutil"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/go-test/deep"
|
||||
"github.com/hashicorp/vault/helper/jsonutil"
|
||||
"github.com/hashicorp/vault/logical"
|
||||
"github.com/hashicorp/vault/version"
|
||||
)
|
||||
|
||||
func TestOpenAPI_Regex(t *testing.T) {
|
||||
t.Run("Required", func(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
captures []string
|
||||
}{
|
||||
{`/foo/bar/(?P<val>.*)`, []string{"val"}},
|
||||
{`/foo/bar/` + GenericNameRegex("val"), []string{"val"}},
|
||||
{`/foo/bar/` + GenericNameRegex("first") + "/b/" + GenericNameRegex("second"), []string{"first", "second"}},
|
||||
{`/foo/bar`, []string{}},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
result := reqdRe.FindAllStringSubmatch(test.input, -1)
|
||||
if len(result) != len(test.captures) {
|
||||
t.Fatalf("Capture error (%s): expected %d matches, actual: %d", test.input, len(test.captures), len(result))
|
||||
}
|
||||
|
||||
for i := 0; i < len(result); i++ {
|
||||
if result[i][1] != test.captures[i] {
|
||||
t.Fatalf("Capture error (%s): expected %s, actual: %s", test.input, test.captures[i], result[i][1])
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
t.Run("Optional", func(t *testing.T) {
|
||||
input := "foo/(maybe/)?bar"
|
||||
expStart := len("foo/")
|
||||
expEnd := len(input) - len("bar")
|
||||
|
||||
match := optRe.FindStringIndex(input)
|
||||
if diff := deep.Equal(match, []int{expStart, expEnd}); diff != nil {
|
||||
t.Fatal(diff)
|
||||
}
|
||||
|
||||
input = "/foo/maybe/bar"
|
||||
match = optRe.FindStringIndex(input)
|
||||
if match != nil {
|
||||
t.Fatalf("Expected nil match (%s), got %+v", input, match)
|
||||
}
|
||||
})
|
||||
t.Run("Alternation", func(t *testing.T) {
|
||||
input := `(raw/?$|raw/(?P<path>.+))`
|
||||
|
||||
matches := altRe.FindAllStringSubmatch(input, -1)
|
||||
exp1 := "raw/?$"
|
||||
exp2 := "raw/(?P<path>.+)"
|
||||
if matches[0][1] != exp1 || matches[0][2] != exp2 {
|
||||
t.Fatalf("Capture error. Expected %s and %s, got %v", exp1, exp2, matches[0][1:])
|
||||
}
|
||||
|
||||
input = `/foo/bar/` + GenericNameRegex("val")
|
||||
|
||||
matches = altRe.FindAllStringSubmatch(input, -1)
|
||||
if matches != nil {
|
||||
t.Fatalf("Expected nil match (%s), got %+v", input, matches)
|
||||
}
|
||||
})
|
||||
t.Run("Path fields", func(t *testing.T) {
|
||||
input := `/foo/bar/{inner}/baz/{outer}`
|
||||
|
||||
matches := pathFieldsRe.FindAllStringSubmatch(input, -1)
|
||||
|
||||
exp1 := "inner"
|
||||
exp2 := "outer"
|
||||
if matches[0][1] != exp1 || matches[1][1] != exp2 {
|
||||
t.Fatalf("Capture error. Expected %s and %s, got %v", exp1, exp2, matches)
|
||||
}
|
||||
|
||||
input = `/foo/bar/inner/baz/outer`
|
||||
matches = pathFieldsRe.FindAllStringSubmatch(input, -1)
|
||||
|
||||
if matches != nil {
|
||||
t.Fatalf("Expected nil match (%s), got %+v", input, matches)
|
||||
}
|
||||
})
|
||||
t.Run("Filtering", func(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
regex *regexp.Regexp
|
||||
output string
|
||||
}{
|
||||
{
|
||||
input: `ab?cde^fg(hi?j$k`,
|
||||
regex: cleanCharsRe,
|
||||
output: "abcdefghijk",
|
||||
},
|
||||
{
|
||||
input: `abcde/?`,
|
||||
regex: cleanSuffixRe,
|
||||
output: "abcde",
|
||||
},
|
||||
{
|
||||
input: `abcde/?$`,
|
||||
regex: cleanSuffixRe,
|
||||
output: "abcde",
|
||||
},
|
||||
{
|
||||
input: `abcde`,
|
||||
regex: wsRe,
|
||||
output: "abcde",
|
||||
},
|
||||
{
|
||||
input: ` a b cd e `,
|
||||
regex: wsRe,
|
||||
output: "abcde",
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
result := test.regex.ReplaceAllString(test.input, "")
|
||||
if result != test.output {
|
||||
t.Fatalf("Clean Regex error (%s). Expected %s, got %s", test.input, test.output, result)
|
||||
}
|
||||
}
|
||||
|
||||
})
|
||||
}
|
||||
|
||||
func TestOpenAPI_ExpandPattern(t *testing.T) {
|
||||
tests := []struct {
|
||||
in_pattern string
|
||||
out_pathlets []string
|
||||
}{
|
||||
{"rekey/backup", []string{"rekey/backup"}},
|
||||
{"rekey/backup$", []string{"rekey/backup"}},
|
||||
{"auth/(?P<path>.+?)/tune$", []string{"auth/{path}/tune"}},
|
||||
{"auth/(?P<path>.+?)/tune/(?P<more>.*?)$", []string{"auth/{path}/tune/{more}"}},
|
||||
{"tools/hash(/(?P<urlalgorithm>.+))?", []string{
|
||||
"tools/hash",
|
||||
"tools/hash/{urlalgorithm}",
|
||||
}},
|
||||
{"(leases/)?renew(/(?P<url_lease_id>.+))?", []string{
|
||||
"leases/renew",
|
||||
"leases/renew/{url_lease_id}",
|
||||
"renew",
|
||||
"renew/{url_lease_id}",
|
||||
}},
|
||||
{`config/ui/headers/` + GenericNameRegex("header"), []string{"config/ui/headers/{header}"}},
|
||||
{`leases/lookup/(?P<prefix>.+?)?`, []string{
|
||||
"leases/lookup/",
|
||||
"leases/lookup/{prefix}",
|
||||
}},
|
||||
{`(raw/?$|raw/(?P<path>.+))`, []string{
|
||||
"raw",
|
||||
"raw/{path}",
|
||||
}},
|
||||
{"lookup" + OptionalParamRegex("urltoken"), []string{
|
||||
"lookup",
|
||||
"lookup/{urltoken}",
|
||||
}},
|
||||
{"roles/?$", []string{
|
||||
"roles",
|
||||
}},
|
||||
{"roles/?", []string{
|
||||
"roles",
|
||||
}},
|
||||
{"accessors/$", []string{
|
||||
"accessors/",
|
||||
}},
|
||||
{"verify/" + GenericNameRegex("name") + OptionalParamRegex("urlalgorithm"), []string{
|
||||
"verify/{name}",
|
||||
"verify/{name}/{urlalgorithm}",
|
||||
}},
|
||||
}
|
||||
|
||||
for i, test := range tests {
|
||||
out := expandPattern(test.in_pattern)
|
||||
sort.Strings(out)
|
||||
if !reflect.DeepEqual(out, test.out_pathlets) {
|
||||
t.Fatalf("Test %d: Expected %v got %v", i, test.out_pathlets, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenAPI_SplitFields(t *testing.T) {
|
||||
fields := map[string]*FieldSchema{
|
||||
"a": {Description: "path"},
|
||||
"b": {Description: "body"},
|
||||
"c": {Description: "body"},
|
||||
"d": {Description: "body"},
|
||||
"e": {Description: "path"},
|
||||
}
|
||||
|
||||
pathFields, bodyFields := splitFields(fields, "some/{a}/path/{e}")
|
||||
|
||||
lp := len(pathFields)
|
||||
lb := len(bodyFields)
|
||||
l := len(fields)
|
||||
if lp+lb != l {
|
||||
t.Fatalf("split length error: %d + %d != %d", lp, lb, l)
|
||||
}
|
||||
|
||||
for name, field := range pathFields {
|
||||
if field.Description != "path" {
|
||||
t.Fatalf("expected field %s to be in 'path', found in %s", name, field.Description)
|
||||
}
|
||||
}
|
||||
for name, field := range bodyFields {
|
||||
if field.Description != "body" {
|
||||
t.Fatalf("expected field %s to be in 'body', found in %s", name, field.Description)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenAPI_SpecialPaths(t *testing.T) {
|
||||
tests := []struct {
|
||||
pattern string
|
||||
rootPaths []string
|
||||
root bool
|
||||
unauthPaths []string
|
||||
unauth bool
|
||||
}{
|
||||
{"foo", []string{}, false, []string{"foo"}, true},
|
||||
{"foo", []string{"foo"}, true, []string{"bar"}, false},
|
||||
{"foo/bar", []string{"foo"}, false, []string{"foo/*"}, true},
|
||||
{"foo/bar", []string{"foo/*"}, true, []string{"foo"}, false},
|
||||
{"foo/", []string{"foo/*"}, true, []string{"a", "b", "foo/"}, true},
|
||||
{"foo", []string{"foo*"}, true, []string{"a", "fo*"}, true},
|
||||
{"foo/bar", []string{"a", "b", "foo/*"}, true, []string{"foo/baz/*"}, false},
|
||||
}
|
||||
for i, test := range tests {
|
||||
doc := NewOASDocument()
|
||||
path := Path{
|
||||
Pattern: test.pattern,
|
||||
}
|
||||
sp := &logical.Paths{
|
||||
Root: test.rootPaths,
|
||||
Unauthenticated: test.unauthPaths,
|
||||
}
|
||||
documentPath(&path, sp, logical.TypeLogical, doc)
|
||||
result := test.root
|
||||
if doc.Paths["/"+test.pattern].Sudo != result {
|
||||
t.Fatalf("Test (root) %d: Expected %v got %v", i, test.root, result)
|
||||
}
|
||||
result = test.unauth
|
||||
if doc.Paths["/"+test.pattern].Unauthenticated != result {
|
||||
t.Fatalf("Test (unauth) %d: Expected %v got %v", i, test.unauth, result)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenAPI_Paths(t *testing.T) {
|
||||
origDepth := deep.MaxDepth
|
||||
defer func() { deep.MaxDepth = origDepth }()
|
||||
deep.MaxDepth = 20
|
||||
|
||||
t.Run("Legacy callbacks", func(t *testing.T) {
|
||||
p := &Path{
|
||||
Pattern: "lookup/" + GenericNameRegex("id"),
|
||||
|
||||
Fields: map[string]*FieldSchema{
|
||||
"id": &FieldSchema{
|
||||
Type: TypeString,
|
||||
Description: "My id parameter",
|
||||
},
|
||||
"token": &FieldSchema{
|
||||
Type: TypeString,
|
||||
Description: "My token",
|
||||
},
|
||||
},
|
||||
|
||||
Callbacks: map[logical.Operation]OperationFunc{
|
||||
logical.ReadOperation: nil,
|
||||
logical.UpdateOperation: nil,
|
||||
},
|
||||
|
||||
HelpSynopsis: "Synopsis",
|
||||
HelpDescription: "Description",
|
||||
}
|
||||
|
||||
sp := &logical.Paths{
|
||||
Root: []string{},
|
||||
Unauthenticated: []string{},
|
||||
}
|
||||
testPath(t, p, sp, expected("legacy"))
|
||||
})
|
||||
|
||||
t.Run("Operations", func(t *testing.T) {
|
||||
p := &Path{
|
||||
Pattern: "foo/" + GenericNameRegex("id"),
|
||||
Fields: map[string]*FieldSchema{
|
||||
"id": {
|
||||
Type: TypeString,
|
||||
Description: "id path parameter",
|
||||
},
|
||||
"flavors": {
|
||||
Type: TypeCommaStringSlice,
|
||||
Description: "the flavors",
|
||||
},
|
||||
"name": {
|
||||
Type: TypeNameString,
|
||||
Description: "the name",
|
||||
},
|
||||
"x-abc-token": {
|
||||
Type: TypeHeader,
|
||||
Description: "a header value",
|
||||
},
|
||||
},
|
||||
HelpSynopsis: "Synopsis",
|
||||
HelpDescription: "Description",
|
||||
Operations: map[logical.Operation]OperationHandler{
|
||||
logical.ReadOperation: &PathOperation{
|
||||
Summary: "My Summary",
|
||||
Description: "My Description",
|
||||
},
|
||||
logical.UpdateOperation: &PathOperation{
|
||||
Summary: "Update Summary",
|
||||
Description: "Update Description",
|
||||
},
|
||||
logical.CreateOperation: &PathOperation{
|
||||
Summary: "Create Summary",
|
||||
Description: "Create Description",
|
||||
},
|
||||
logical.ListOperation: &PathOperation{
|
||||
Summary: "List Summary",
|
||||
Description: "List Description",
|
||||
},
|
||||
logical.DeleteOperation: &PathOperation{
|
||||
Summary: "This shouldn't show up",
|
||||
Unpublished: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
sp := &logical.Paths{
|
||||
Root: []string{"foo*"},
|
||||
}
|
||||
testPath(t, p, sp, expected("operations"))
|
||||
})
|
||||
|
||||
t.Run("Responses", func(t *testing.T) {
|
||||
p := &Path{
|
||||
Pattern: "foo",
|
||||
HelpSynopsis: "Synopsis",
|
||||
HelpDescription: "Description",
|
||||
Operations: map[logical.Operation]OperationHandler{
|
||||
logical.ReadOperation: &PathOperation{
|
||||
Summary: "My Summary",
|
||||
Description: "My Description",
|
||||
Responses: map[int][]Response{
|
||||
202: {{
|
||||
Description: "Amazing",
|
||||
Example: &logical.Response{
|
||||
Data: map[string]interface{}{
|
||||
"amount": 42,
|
||||
},
|
||||
},
|
||||
}},
|
||||
},
|
||||
},
|
||||
logical.DeleteOperation: &PathOperation{
|
||||
Summary: "Delete stuff",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
sp := &logical.Paths{
|
||||
Unauthenticated: []string{"x", "y", "foo"},
|
||||
}
|
||||
|
||||
testPath(t, p, sp, expected("responses"))
|
||||
})
|
||||
}
|
||||
|
||||
func TestOpenAPI_CustomDecoder(t *testing.T) {
|
||||
p := &Path{
|
||||
Pattern: "foo",
|
||||
HelpSynopsis: "Synopsis",
|
||||
Operations: map[logical.Operation]OperationHandler{
|
||||
logical.ReadOperation: &PathOperation{
|
||||
Summary: "My Summary",
|
||||
Responses: map[int][]Response{
|
||||
100: {{
|
||||
Description: "OK",
|
||||
Example: &logical.Response{},
|
||||
}},
|
||||
200: {{
|
||||
Description: "Good",
|
||||
Example: &logical.Response{},
|
||||
}},
|
||||
599: {{
|
||||
Description: "Bad",
|
||||
Example: &logical.Response{},
|
||||
}},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
docOrig := NewOASDocument()
|
||||
documentPath(p, nil, logical.TypeLogical, docOrig)
|
||||
|
||||
docJSON, err := json.Marshal(docOrig)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var intermediate map[string]interface{}
|
||||
if err := jsonutil.DecodeJSON(docJSON, &intermediate); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
docNew, err := NewOASDocumentFromMap(intermediate)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if diff := deep.Equal(docOrig, docNew); diff != nil {
|
||||
t.Fatal(diff)
|
||||
}
|
||||
}
|
||||
|
||||
func testPath(t *testing.T, path *Path, sp *logical.Paths, expectedJSON string) {
|
||||
t.Helper()
|
||||
|
||||
doc := NewOASDocument()
|
||||
documentPath(path, sp, logical.TypeLogical, doc)
|
||||
|
||||
docJSON, err := json.MarshalIndent(doc, "", " ")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Compare json by first decoding, then comparing with a deep equality check.
|
||||
var expected, actual interface{}
|
||||
if err := jsonutil.DecodeJSON(docJSON, &actual); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := jsonutil.DecodeJSON([]byte(expectedJSON), &expected); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if diff := deep.Equal(actual, expected); diff != nil {
|
||||
//fmt.Println(string(docJSON)) // uncomment to debug generated JSON (very helpful when fixing tests)
|
||||
t.Fatal(diff)
|
||||
}
|
||||
}
|
||||
|
||||
func expected(name string) string {
|
||||
data, err := ioutil.ReadFile(filepath.Join("testdata", name+".json"))
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
content := strings.Replace(string(data), "<vault_version>", version.GetVersion().Version, 1)
|
||||
|
||||
return content
|
||||
}
|
||||
@@ -29,6 +29,12 @@ func OptionalParamRegex(name string) string {
|
||||
return fmt.Sprintf("(/(?P<%s>.+))?", name)
|
||||
}
|
||||
|
||||
// Helper which returns a regex string for capturing an entire endpoint path
|
||||
// as the given name.
|
||||
func MatchAllRegex(name string) string {
|
||||
return fmt.Sprintf(`(?P<%s>.*)`, name)
|
||||
}
|
||||
|
||||
// PathAppend is a helper for appending lists of paths into a single
|
||||
// list.
|
||||
func PathAppend(paths ...[]*Path) []*Path {
|
||||
@@ -58,6 +64,13 @@ type Path struct {
|
||||
// whereas all fields are available in the Write operation.
|
||||
Fields map[string]*FieldSchema
|
||||
|
||||
// Operations is the set of operations supported and the associated OperationsHandler.
|
||||
//
|
||||
// If both Create and Update operations are present, documentation and examples from
|
||||
// the Update definition will be used. Similarly if both Read and List are present,
|
||||
// Read will be used for documentation.
|
||||
Operations map[logical.Operation]OperationHandler
|
||||
|
||||
// Callbacks are the set of callbacks that are called for a given
|
||||
// operation. If a callback for a specific operation is not present,
|
||||
// then logical.ErrUnsupportedOperation is automatically generated.
|
||||
@@ -66,6 +79,8 @@ type Path struct {
|
||||
// automatically handle if the Help field is set. If both the Help
|
||||
// field is set and there is a callback registered here, then the
|
||||
// callback will be called.
|
||||
//
|
||||
// Deprecated: Operations should be used instead and will take priority if present.
|
||||
Callbacks map[logical.Operation]OperationFunc
|
||||
|
||||
// ExistenceCheck, if implemented, is used to query whether a given
|
||||
@@ -80,6 +95,10 @@ type Path struct {
|
||||
// enabled for the set of paths
|
||||
FeatureRequired license.Features
|
||||
|
||||
// Deprecated denotes that this path is considered deprecated. This may
|
||||
// be reflected in help and documentation.
|
||||
Deprecated bool
|
||||
|
||||
// Help is text describing how to use this path. This will be used
|
||||
// to auto-generate the help operation. The Path will automatically
|
||||
// generate a parameter listing and URL structure based on the
|
||||
@@ -95,7 +114,86 @@ type Path struct {
|
||||
HelpDescription string
|
||||
}
|
||||
|
||||
func (p *Path) helpCallback() OperationFunc {
|
||||
// OperationHandler defines and describes a specific operation handler.
|
||||
type OperationHandler interface {
|
||||
Handler() OperationFunc
|
||||
Properties() OperationProperties
|
||||
}
|
||||
|
||||
// OperationProperties describes an operation for documentation, help text,
|
||||
// and other clients. A Summary should always be provided, whereas other
|
||||
// fields can be populated as needed.
|
||||
type OperationProperties struct {
|
||||
// Summary is a brief (usually one line) description of the operation.
|
||||
Summary string
|
||||
|
||||
// Description is extended documentation of the operation and may contain
|
||||
// Markdown-formatted text markup.
|
||||
Description string
|
||||
|
||||
// Examples provides samples of the expected request data. The most
|
||||
// relevant example should be first in the list, as it will be shown in
|
||||
// documentation that supports only a single example.
|
||||
Examples []RequestExample
|
||||
|
||||
// Responses provides a list of response description for a given response
|
||||
// code. The most relevant response should be first in the list, as it will
|
||||
// be shown in documentation that only allows a single example.
|
||||
Responses map[int][]Response
|
||||
|
||||
// Unpublished indicates that this operation should not appear in public
|
||||
// documentation or help text. The operation may still have documentation
|
||||
// attached that can be used internally.
|
||||
Unpublished bool
|
||||
|
||||
// Deprecated indicates that this operation should be avoided.
|
||||
Deprecated bool
|
||||
}
|
||||
|
||||
// RequestExample is example of request data.
|
||||
type RequestExample struct {
|
||||
Description string // optional description of the request
|
||||
Data map[string]interface{} // map version of sample JSON request data
|
||||
|
||||
// Optional example response to the sample request. This approach is considered
|
||||
// provisional for now, and this field may be changed or removed.
|
||||
Response *Response
|
||||
}
|
||||
|
||||
// Response describes and optional demonstrations an operation response.
|
||||
type Response struct {
|
||||
Description string // summary of the the response and should always be provided
|
||||
MediaType string // media type of the response, defaulting to "application/json" if empty
|
||||
Example *logical.Response // example response data
|
||||
}
|
||||
|
||||
// PathOperation is a concrete implementation of OperationHandler.
|
||||
type PathOperation struct {
|
||||
Callback OperationFunc
|
||||
Summary string
|
||||
Description string
|
||||
Examples []RequestExample
|
||||
Responses map[int][]Response
|
||||
Unpublished bool
|
||||
Deprecated bool
|
||||
}
|
||||
|
||||
func (p *PathOperation) Handler() OperationFunc {
|
||||
return p.Callback
|
||||
}
|
||||
|
||||
func (p *PathOperation) Properties() OperationProperties {
|
||||
return OperationProperties{
|
||||
Summary: strings.TrimSpace(p.Summary),
|
||||
Description: strings.TrimSpace(p.Description),
|
||||
Responses: p.Responses,
|
||||
Examples: p.Examples,
|
||||
Unpublished: p.Unpublished,
|
||||
Deprecated: p.Deprecated,
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Path) helpCallback(b *Backend) OperationFunc {
|
||||
return func(ctx context.Context, req *logical.Request, data *FieldData) (*logical.Response, error) {
|
||||
var tplData pathTemplateData
|
||||
tplData.Request = req.Path
|
||||
@@ -137,7 +235,13 @@ func (p *Path) helpCallback() OperationFunc {
|
||||
return nil, errwrap.Wrapf("error executing template: {{err}}", err)
|
||||
}
|
||||
|
||||
return logical.HelpResponse(help, nil), nil
|
||||
// Build OpenAPI response for this path
|
||||
doc := NewOASDocument()
|
||||
if err := documentPath(p, b.SpecialPaths(), b.BackendType, doc); err != nil {
|
||||
b.Logger().Warn("error generating OpenAPI", "error", err)
|
||||
}
|
||||
|
||||
return logical.HelpResponse(help, nil, doc), nil
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
98
logical/framework/path_test.go
Normal file
98
logical/framework/path_test.go
Normal file
@@ -0,0 +1,98 @@
|
||||
package framework
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/go-test/deep"
|
||||
)
|
||||
|
||||
func TestPath_Regex(t *testing.T) {
|
||||
tests := []struct {
|
||||
pattern string
|
||||
input string
|
||||
pathMatch bool
|
||||
captures map[string]string
|
||||
}{
|
||||
{
|
||||
pattern: "a/b/" + GenericNameRegex("val"),
|
||||
input: "a/b/foo",
|
||||
pathMatch: true,
|
||||
captures: map[string]string{"val": "foo"},
|
||||
},
|
||||
{
|
||||
pattern: "a/b/" + GenericNameRegex("val"),
|
||||
input: "a/b/foo/more",
|
||||
pathMatch: false,
|
||||
captures: nil,
|
||||
},
|
||||
{
|
||||
pattern: "a/b/" + GenericNameRegex("val"),
|
||||
input: "a/b/abc-.123",
|
||||
pathMatch: true,
|
||||
captures: map[string]string{"val": "abc-.123"},
|
||||
},
|
||||
{
|
||||
pattern: "a/b/" + GenericNameRegex("val") + "/c/d",
|
||||
input: "a/b/foo/c/d",
|
||||
pathMatch: true,
|
||||
captures: map[string]string{"val": "foo"},
|
||||
},
|
||||
{
|
||||
pattern: "a/b/" + GenericNameRegex("val") + "/c/d",
|
||||
input: "a/b/foo/c/d/e",
|
||||
pathMatch: false,
|
||||
captures: nil,
|
||||
},
|
||||
{
|
||||
pattern: "a/b" + OptionalParamRegex("val"),
|
||||
input: "a/b",
|
||||
pathMatch: true,
|
||||
captures: map[string]string{"val": ""},
|
||||
},
|
||||
{
|
||||
pattern: "a/b" + OptionalParamRegex("val"),
|
||||
input: "a/b/foo",
|
||||
pathMatch: true,
|
||||
captures: map[string]string{"val": "foo"},
|
||||
},
|
||||
{
|
||||
pattern: "foo/" + MatchAllRegex("val"),
|
||||
input: "foos/ball",
|
||||
pathMatch: false,
|
||||
captures: nil,
|
||||
},
|
||||
{
|
||||
pattern: "foos/" + MatchAllRegex("val"),
|
||||
input: "foos/ball",
|
||||
pathMatch: true,
|
||||
captures: map[string]string{"val": "ball"},
|
||||
},
|
||||
{
|
||||
pattern: "foos/ball/" + MatchAllRegex("val"),
|
||||
input: "foos/ball/with/more/stuff/at_the/end",
|
||||
pathMatch: true,
|
||||
captures: map[string]string{"val": "with/more/stuff/at_the/end"},
|
||||
},
|
||||
{
|
||||
pattern: MatchAllRegex("val"),
|
||||
input: "foos/ball/with/more/stuff/at_the/end",
|
||||
pathMatch: true,
|
||||
captures: map[string]string{"val": "foos/ball/with/more/stuff/at_the/end"},
|
||||
},
|
||||
}
|
||||
|
||||
for i, test := range tests {
|
||||
b := Backend{
|
||||
Paths: []*Path{{Pattern: test.pattern}},
|
||||
}
|
||||
path, captures := b.route(test.input)
|
||||
pathMatch := path != nil
|
||||
if pathMatch != test.pathMatch {
|
||||
t.Fatalf("[%d] unexpected path match result (%s): expected %t, actual %t", i, test.pattern, test.pathMatch, pathMatch)
|
||||
}
|
||||
if diff := deep.Equal(captures, test.captures); diff != nil {
|
||||
t.Fatal(diff)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
62
logical/framework/testdata/legacy.json
vendored
Normal file
62
logical/framework/testdata/legacy.json
vendored
Normal file
@@ -0,0 +1,62 @@
|
||||
{
|
||||
"openapi": "3.0.2",
|
||||
"info": {
|
||||
"title": "HashiCorp Vault API",
|
||||
"description": "HTTP API that gives you full access to Vault. All API routes are prefixed with `/v1/`.",
|
||||
"version": "<vault_version>",
|
||||
"license": {
|
||||
"name": "Mozilla Public License 2.0",
|
||||
"url": "https://www.mozilla.org/en-US/MPL/2.0"
|
||||
}
|
||||
},
|
||||
"paths": {
|
||||
"/lookup/{id}": {
|
||||
"description": "Synopsis",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
"description": "My id parameter",
|
||||
"in": "path",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
},
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"get": {
|
||||
"summary": "Synopsis",
|
||||
"tags": ["secrets"],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK"
|
||||
}
|
||||
}
|
||||
},
|
||||
"post": {
|
||||
"summary": "Synopsis",
|
||||
"tags": ["secrets"],
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"token": {
|
||||
"type": "string",
|
||||
"description": "My token"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
91
logical/framework/testdata/operations.json
vendored
Normal file
91
logical/framework/testdata/operations.json
vendored
Normal file
@@ -0,0 +1,91 @@
|
||||
{
|
||||
"openapi": "3.0.2",
|
||||
"info": {
|
||||
"title": "HashiCorp Vault API",
|
||||
"description": "HTTP API that gives you full access to Vault. All API routes are prefixed with `/v1/`.",
|
||||
"version": "<vault_version>",
|
||||
"license": {
|
||||
"name": "Mozilla Public License 2.0",
|
||||
"url": "https://www.mozilla.org/en-US/MPL/2.0"
|
||||
}
|
||||
},
|
||||
"paths": {
|
||||
"/foo/{id}": {
|
||||
"description": "Synopsis",
|
||||
"x-vault-create-supported": true,
|
||||
"x-vault-sudo": true,
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
"description": "id path parameter",
|
||||
"in": "path",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"name": "x-abc-token",
|
||||
"description": "a header value",
|
||||
"in": "header",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"get": {
|
||||
"tags": ["secrets"],
|
||||
"summary": "My Summary",
|
||||
"description": "My Description",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK"
|
||||
}
|
||||
},
|
||||
"parameters": [
|
||||
{
|
||||
"name": "list",
|
||||
"description": "Return a list if `true`",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"post": {
|
||||
"tags": ["secrets"],
|
||||
"summary": "Update Summary",
|
||||
"description": "Update Description",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"flavors": {
|
||||
"type": "array",
|
||||
"description": "the flavors",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "the name",
|
||||
"pattern": "\\w([\\w-.]*\\w)?"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
49
logical/framework/testdata/responses.json
vendored
Normal file
49
logical/framework/testdata/responses.json
vendored
Normal file
@@ -0,0 +1,49 @@
|
||||
{
|
||||
"openapi": "3.0.2",
|
||||
"info": {
|
||||
"title": "HashiCorp Vault API",
|
||||
"description": "HTTP API that gives you full access to Vault. All API routes are prefixed with `/v1/`.",
|
||||
"version": "<vault_version>",
|
||||
"license": {
|
||||
"name": "Mozilla Public License 2.0",
|
||||
"url": "https://www.mozilla.org/en-US/MPL/2.0"
|
||||
}
|
||||
},
|
||||
"paths": {
|
||||
"/foo": {
|
||||
"description": "Synopsis",
|
||||
"x-vault-unauthenticated": true,
|
||||
"delete": {
|
||||
"tags": ["secrets"],
|
||||
"summary": "Delete stuff",
|
||||
"responses": {
|
||||
"204": {
|
||||
"description": "empty body"
|
||||
}
|
||||
}
|
||||
},
|
||||
"get": {
|
||||
"tags": ["secrets"],
|
||||
"summary": "My Summary",
|
||||
"description": "My Description",
|
||||
"responses": {
|
||||
"202": {
|
||||
"description": "Amazing",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"example": {
|
||||
"data": {
|
||||
"amount": 42
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,11 +89,12 @@ func (r *Response) Error() error {
|
||||
}
|
||||
|
||||
// HelpResponse is used to format a help response
|
||||
func HelpResponse(text string, seeAlso []string) *Response {
|
||||
func HelpResponse(text string, seeAlso []string, oapiDoc interface{}) *Response {
|
||||
return &Response{
|
||||
Data: map[string]interface{}{
|
||||
"help": text,
|
||||
"see_also": seeAlso,
|
||||
"openapi": oapiDoc,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
69
scripts/gen_openapi.sh
Executable file
69
scripts/gen_openapi.sh
Executable file
@@ -0,0 +1,69 @@
|
||||
#!/bin/sh
|
||||
|
||||
set -e
|
||||
|
||||
# Generate an OpenAPI document for all backends.
|
||||
#
|
||||
# Assumptions:
|
||||
#
|
||||
# 1. Vault has been checked out at an appropriate version and built
|
||||
# 2. vault executable is in your path
|
||||
# 3. Vault isn't alredy running
|
||||
|
||||
echo "Starting Vault..."
|
||||
if pgrep -x "vault" > /dev/null
|
||||
then
|
||||
echo "Vault is already running. Aborting."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
vault server -dev -dev-root-token-id=root &
|
||||
sleep 2
|
||||
VAULT_PID=$!
|
||||
|
||||
echo "Mounting all builtin backends..."
|
||||
|
||||
# auth backends
|
||||
vault auth enable alicloud
|
||||
vault auth enable app-id
|
||||
vault auth enable approle
|
||||
vault auth enable aws
|
||||
vault auth enable azure
|
||||
vault auth enable centrify
|
||||
vault auth enable cert
|
||||
vault auth enable gcp
|
||||
vault auth enable github
|
||||
vault auth enable jwt
|
||||
vault auth enable kubernetes
|
||||
vault auth enable ldap
|
||||
vault auth enable okta
|
||||
vault auth enable radius
|
||||
vault auth enable userpass
|
||||
|
||||
# secrets backends
|
||||
vault secrets enable ad
|
||||
vault secrets enable alicloud
|
||||
vault secrets enable aws
|
||||
vault secrets enable azure
|
||||
vault secrets enable cassandra
|
||||
vault secrets enable consul
|
||||
vault secrets enable database
|
||||
vault secrets enable gcp
|
||||
vault secrets enable kv
|
||||
vault secrets enable mongodb
|
||||
vault secrets enable mssql
|
||||
vault secrets enable mysql
|
||||
vault secrets enable nomad
|
||||
vault secrets enable pki
|
||||
vault secrets enable postgresql
|
||||
vault secrets enable rabbitmq
|
||||
vault secrets enable ssh
|
||||
vault secrets enable totp
|
||||
vault secrets enable transit
|
||||
|
||||
curl -H "X-Vault-Token: root" "http://127.0.0.1:8200/v1/sys/internal/specs/openapi" > openapi.json
|
||||
|
||||
kill $VAULT_PID
|
||||
sleep 1
|
||||
|
||||
echo "\nopenapi.json generated."
|
||||
@@ -108,6 +108,7 @@ func NewSystemBackend(core *Core, logger log.Logger) *SystemBackend {
|
||||
"wrapping/lookup",
|
||||
"wrapping/pubkey",
|
||||
"replication/status",
|
||||
"internal/specs/openapi",
|
||||
"internal/ui/mounts",
|
||||
"internal/ui/mounts/*",
|
||||
"internal/ui/namespaces",
|
||||
@@ -141,7 +142,7 @@ func NewSystemBackend(core *Core, logger log.Logger) *SystemBackend {
|
||||
b.Backend.Paths = append(b.Backend.Paths, b.wrappingPaths()...)
|
||||
b.Backend.Paths = append(b.Backend.Paths, b.toolsPaths()...)
|
||||
b.Backend.Paths = append(b.Backend.Paths, b.capabilitiesPaths()...)
|
||||
b.Backend.Paths = append(b.Backend.Paths, b.internalUIPaths()...)
|
||||
b.Backend.Paths = append(b.Backend.Paths, b.internalPaths()...)
|
||||
b.Backend.Paths = append(b.Backend.Paths, b.remountPath())
|
||||
|
||||
if core.rawEnabled {
|
||||
@@ -2958,6 +2959,110 @@ func (b *SystemBackend) pathInternalUIResultantACL(ctx context.Context, req *log
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func (b *SystemBackend) pathInternalOpenAPI(ctx context.Context, req *logical.Request, d *framework.FieldData) (*logical.Response, error) {
|
||||
|
||||
// Limit output to authorized paths
|
||||
resp, err := b.pathInternalUIMountsRead(ctx, req, d)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Set up target document and convert to map[string]interface{} which is what will
|
||||
// be received from plugin backends.
|
||||
doc := framework.NewOASDocument()
|
||||
|
||||
procMountGroup := func(group, mountPrefix string) error {
|
||||
for mount := range resp.Data[group].(map[string]interface{}) {
|
||||
backend := b.Core.router.MatchingBackend(ctx, mountPrefix+mount)
|
||||
|
||||
if backend == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
req := &logical.Request{
|
||||
Operation: logical.HelpOperation,
|
||||
}
|
||||
|
||||
resp, err := backend.HandleRequest(ctx, req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var backendDoc *framework.OASDocument
|
||||
|
||||
// Normalize response type, which will be different if received
|
||||
// from an external plugin.
|
||||
switch v := resp.Data["openapi"].(type) {
|
||||
case *framework.OASDocument:
|
||||
backendDoc = v
|
||||
case map[string]interface{}:
|
||||
backendDoc, err = framework.NewOASDocumentFromMap(v)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
default:
|
||||
continue
|
||||
}
|
||||
|
||||
// Prepare to add tags to default builtins that are
|
||||
// type "unknown" and won't already be tagged.
|
||||
var tag string
|
||||
switch mountPrefix + mount {
|
||||
case "cubbyhole/", "secret/":
|
||||
tag = "secrets"
|
||||
case "sys/":
|
||||
tag = "system"
|
||||
case "auth/token/":
|
||||
tag = "auth"
|
||||
case "identity/":
|
||||
tag = "identity"
|
||||
}
|
||||
|
||||
// Merge backend paths with existing document
|
||||
for path, obj := range backendDoc.Paths {
|
||||
path := strings.TrimPrefix(path, "/")
|
||||
|
||||
// Add tags to all of the operations if necessary
|
||||
if tag != "" {
|
||||
for _, op := range []*framework.OASOperation{obj.Get, obj.Post, obj.Delete} {
|
||||
// TODO: a special override for identity is used used here because the backend
|
||||
// is currently categorized as "secret", which will likely change. Also of interest
|
||||
// is removing all tag handling here and providing the mount information to OpenAPI.
|
||||
if op != nil && (len(op.Tags) == 0 || tag == "identity") {
|
||||
op.Tags = []string{tag}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
doc.Paths["/"+mountPrefix+mount+path] = obj
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := procMountGroup("secret", ""); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := procMountGroup("auth", "auth/"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
buf, err := json.Marshal(doc)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resp = &logical.Response{
|
||||
Data: map[string]interface{}{
|
||||
logical.HTTPStatusCode: 200,
|
||||
logical.HTTPRawBody: buf,
|
||||
logical.HTTPContentType: "application/json",
|
||||
},
|
||||
}
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func sanitizeMountPath(path string) string {
|
||||
if !strings.HasSuffix(path, "/") {
|
||||
path += "/"
|
||||
|
||||
@@ -423,8 +423,14 @@ func (b *SystemBackend) toolsPaths() []*framework.Path {
|
||||
}
|
||||
}
|
||||
|
||||
func (b *SystemBackend) internalUIPaths() []*framework.Path {
|
||||
func (b *SystemBackend) internalPaths() []*framework.Path {
|
||||
return []*framework.Path{
|
||||
{
|
||||
Pattern: "internal/specs/openapi",
|
||||
Callbacks: map[logical.Operation]framework.OperationFunc{
|
||||
logical.ReadOperation: b.pathInternalOpenAPI,
|
||||
},
|
||||
},
|
||||
{
|
||||
Pattern: "internal/ui/mounts",
|
||||
Callbacks: map[logical.Operation]framework.OperationFunc{
|
||||
|
||||
@@ -19,9 +19,12 @@ import (
|
||||
hclog "github.com/hashicorp/go-hclog"
|
||||
"github.com/hashicorp/vault/audit"
|
||||
"github.com/hashicorp/vault/helper/builtinplugins"
|
||||
"github.com/hashicorp/vault/helper/jsonutil"
|
||||
"github.com/hashicorp/vault/helper/namespace"
|
||||
"github.com/hashicorp/vault/helper/salt"
|
||||
"github.com/hashicorp/vault/logical"
|
||||
"github.com/hashicorp/vault/logical/framework"
|
||||
"github.com/hashicorp/vault/version"
|
||||
"github.com/mitchellh/mapstructure"
|
||||
)
|
||||
|
||||
@@ -2447,3 +2450,102 @@ func TestSystemBackend_InternalUIMount(t *testing.T) {
|
||||
t.Fatal("expected permission denied error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSystemBackend_OpenAPI(t *testing.T) {
|
||||
_, b, rootToken := testCoreSystemBackend(t)
|
||||
var oapi map[string]interface{}
|
||||
|
||||
// Ensure no paths are reported if there is no token
|
||||
req := logical.TestRequest(t, logical.ReadOperation, "internal/specs/openapi")
|
||||
resp, err := b.HandleRequest(namespace.RootContext(nil), req)
|
||||
if err != nil {
|
||||
t.Fatalf("err: %v", err)
|
||||
}
|
||||
|
||||
body := resp.Data["http_raw_body"].([]byte)
|
||||
err = jsonutil.DecodeJSON(body, &oapi)
|
||||
if err != nil {
|
||||
t.Fatalf("err: %v", err)
|
||||
}
|
||||
exp := map[string]interface{}{
|
||||
"openapi": framework.OASVersion,
|
||||
"info": map[string]interface{}{
|
||||
"title": "HashiCorp Vault API",
|
||||
"description": "HTTP API that gives you full access to Vault. All API routes are prefixed with `/v1/`.",
|
||||
"version": version.GetVersion().Version,
|
||||
"license": map[string]interface{}{
|
||||
"name": "Mozilla Public License 2.0",
|
||||
"url": "https://www.mozilla.org/en-US/MPL/2.0",
|
||||
},
|
||||
},
|
||||
"paths": map[string]interface{}{},
|
||||
}
|
||||
|
||||
if diff := deep.Equal(oapi, exp); diff != nil {
|
||||
t.Fatal(diff)
|
||||
}
|
||||
|
||||
// Check that default paths are present with a root token
|
||||
req = logical.TestRequest(t, logical.ReadOperation, "internal/specs/openapi")
|
||||
req.ClientToken = rootToken
|
||||
resp, err = b.HandleRequest(namespace.RootContext(nil), req)
|
||||
if err != nil {
|
||||
t.Fatalf("err: %v", err)
|
||||
}
|
||||
|
||||
body = resp.Data["http_raw_body"].([]byte)
|
||||
err = jsonutil.DecodeJSON(body, &oapi)
|
||||
if err != nil {
|
||||
t.Fatalf("err: %v", err)
|
||||
}
|
||||
|
||||
doc, err := framework.NewOASDocumentFromMap(oapi)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
pathSamples := []struct {
|
||||
path string
|
||||
tag string
|
||||
}{
|
||||
{"/auth/token/lookup", "auth"},
|
||||
{"/cubbyhole/.*", "secrets"}, // TODO update after sys docs update
|
||||
{"/identity/group/id", "identity"},
|
||||
{"/secret/.*", "secrets"}, // TODO update after sys docs update
|
||||
{"/sys/policy", "system"},
|
||||
}
|
||||
|
||||
for _, path := range pathSamples {
|
||||
if doc.Paths[path.path] == nil {
|
||||
t.Fatalf("didn't find expected path '%s'.", path)
|
||||
}
|
||||
tag := doc.Paths[path.path].Get.Tags[0]
|
||||
if tag != path.tag {
|
||||
t.Fatalf("path: %s; expected tag: %s, actual: %s", path.path, tag, path.tag)
|
||||
}
|
||||
}
|
||||
|
||||
// Simple sanity check of response size (which is much larger than most
|
||||
// Vault responses), mainly to catch mass omission of expected path data.
|
||||
minLen := 70000
|
||||
if len(body) < minLen {
|
||||
t.Fatalf("response size too small; expected: min %d, actual: %d", minLen, len(body))
|
||||
}
|
||||
|
||||
// Test path-help response
|
||||
req = logical.TestRequest(t, logical.HelpOperation, "rotate")
|
||||
req.ClientToken = rootToken
|
||||
resp, err = b.HandleRequest(namespace.RootContext(nil), req)
|
||||
if err != nil {
|
||||
t.Fatalf("err: %v", err)
|
||||
}
|
||||
|
||||
doc = resp.Data["openapi"].(*framework.OASDocument)
|
||||
if len(doc.Paths) != 1 {
|
||||
t.Fatalf("expected 1 path, actual: %d", len(doc.Paths))
|
||||
}
|
||||
|
||||
if doc.Paths["/rotate"] == nil {
|
||||
t.Fatalf("expected to find path '/rotate'")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user