mirror of
https://github.com/hashicorp/vault.git
synced 2025-08-08 23:57:01 +02:00
* OpenAPI: Separate ListOperation from ReadOperation Historically, since Vault's ReadOperation and ListOperation both map to the HTTP GET method, their representation in the generated OpenAPI has been a bit confusing. This was partially mitigated some time ago, by making the `list` query parameter express whether it was required or optional - but only in a way useful to human readers - the human had to know, for example, that the schema of the response body would change depending on whether `list` was selected. Now that there is an effort underway to automatically generate API clients from the OpenAPI spec, we have a need to fix this more comprehensively. Fortunately, we do have a means to do so - since Vault has opinionated treatment of trailing slashes, linked to operations being list or not, we can use an added trailing slash on the URL path to separate list operations in the OpenAPI spec. This PR implements that, and then fixes an operation ID which becomes duplicated, with this change applied. See also hashicorp/vault-client-go#174, a bug which will be fixed by this work. * Set further DisplayAttrs in auth/github plugin To mask out more duplicate read/list functionality, now being separately generated to OpenAPI client libraries as a result of this change. * Apply requested changes to operation IDs I'm not totally convinced its worth the extra lines of code, but equally, I don't have strong feelings about it, so I'll just make the change. * Adjust logic to prevent any possibility of generating OpenAPI paths with doubled final slashes Even in the edge case of improper use of regex patterns and operations. * changelog * Fix TestSudoPaths to pass again... which snowballed a bit... Once I looked hard at it, I found it was missing several sudo paths, which led to additional bug fixing elsewhere. I might need to pull some parts of this change out into a separate PR for ease of review... * Fix other tests * More test fixing * Undo scope creep - back away from fixing sudo paths not shown as such in OpenAPI, at least within this PR Just add TODO comments for now.
1169 lines
38 KiB
Go
1169 lines
38 KiB
Go
package framework
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"reflect"
|
|
"regexp"
|
|
"regexp/syntax"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
|
|
log "github.com/hashicorp/go-hclog"
|
|
"github.com/hashicorp/vault/sdk/helper/wrapping"
|
|
"github.com/hashicorp/vault/sdk/logical"
|
|
"github.com/mitchellh/mapstructure"
|
|
"golang.org/x/text/cases"
|
|
"golang.org/x/text/language"
|
|
)
|
|
|
|
// 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(version string) *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,
|
|
License: OASLicense{
|
|
Name: "Mozilla Public License 2.0",
|
|
URL: "https://www.mozilla.org/en-US/MPL/2.0",
|
|
},
|
|
},
|
|
Paths: make(map[string]*OASPathItem),
|
|
Components: OASComponents{
|
|
Schemas: make(map[string]*OASSchema),
|
|
},
|
|
}
|
|
}
|
|
|
|
// 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"`
|
|
Components OASComponents `json:"components"`
|
|
}
|
|
|
|
type OASComponents struct {
|
|
Schemas map[string]*OASSchema `json:"schemas"`
|
|
}
|
|
|
|
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-createSupported,omitempty" mapstructure:"x-vault-createSupported"`
|
|
DisplayAttrs *DisplayAttributes `json:"x-vault-displayAttrs,omitempty" mapstructure:"x-vault-displayAttrs"`
|
|
|
|
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"`
|
|
OperationID string `json:"operationId,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"`
|
|
Required bool `json:"required,omitempty"`
|
|
Content OASContent `json:"content,omitempty"`
|
|
}
|
|
|
|
type OASContent map[string]*OASMediaTypeObject
|
|
|
|
type OASMediaTypeObject struct {
|
|
Schema *OASSchema `json:"schema,omitempty"`
|
|
}
|
|
|
|
type OASSchema struct {
|
|
Ref string `json:"$ref,omitempty"`
|
|
Type string `json:"type,omitempty"`
|
|
Description string `json:"description,omitempty"`
|
|
Properties map[string]*OASSchema `json:"properties,omitempty"`
|
|
|
|
// Required is a list of keys in Properties that are required to be present. This is a different
|
|
// approach than OASParameter (unfortunately), but is how JSONSchema handles 'required'.
|
|
Required []string `json:"required,omitempty"`
|
|
|
|
Items *OASSchema `json:"items,omitempty"`
|
|
Format string `json:"format,omitempty"`
|
|
Pattern string `json:"pattern,omitempty"`
|
|
Enum []interface{} `json:"enum,omitempty"`
|
|
Default interface{} `json:"default,omitempty"`
|
|
Example interface{} `json:"example,omitempty"`
|
|
Deprecated bool `json:"deprecated,omitempty"`
|
|
// DisplayName string `json:"x-vault-displayName,omitempty" mapstructure:"x-vault-displayName,omitempty"`
|
|
DisplayValue interface{} `json:"x-vault-displayValue,omitempty" mapstructure:"x-vault-displayValue,omitempty"`
|
|
DisplaySensitive bool `json:"x-vault-displaySensitive,omitempty" mapstructure:"x-vault-displaySensitive,omitempty"`
|
|
DisplayGroup string `json:"x-vault-displayGroup,omitempty" mapstructure:"x-vault-displayGroup,omitempty"`
|
|
DisplayAttrs *DisplayAttributes `json:"x-vault-displayAttrs,omitempty" mapstructure:"x-vault-displayAttrs,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 fields in paths, and string cleanup.
|
|
// Predefined here to avoid substantial recompilation.
|
|
|
|
var (
|
|
nonWordRe = regexp.MustCompile(`[^\w]+`) // Match a sequence of non-word characters
|
|
pathFieldsRe = regexp.MustCompile(`{(\w+)}`) // Capture OpenAPI-style named parameters, e.g. "lookup/{urltoken}",
|
|
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, requestResponsePrefix string, doc *OASDocument) error {
|
|
for _, p := range backend.Paths {
|
|
if err := documentPath(p, backend, requestResponsePrefix, doc); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// documentPath parses a framework.Path into one or more OpenAPI paths.
|
|
func documentPath(p *Path, backend *Backend, requestResponsePrefix string, doc *OASDocument) error {
|
|
var sudoPaths []string
|
|
var unauthPaths []string
|
|
|
|
if backend.PathsSpecial != nil {
|
|
sudoPaths = backend.PathsSpecial.Root
|
|
unauthPaths = backend.PathsSpecial.Unauthenticated
|
|
}
|
|
|
|
// Convert optional parameters into distinct patterns to be processed independently.
|
|
forceUnpublished := false
|
|
paths, err := expandPattern(p.Pattern)
|
|
if err != nil {
|
|
if errors.Is(err, errUnsupportableRegexpOperationForOpenAPI) {
|
|
// Pattern cannot be transformed into sensible OpenAPI paths. In this case, we override the later
|
|
// processing to use the regexp, as is, as the path, and behave as if Unpublished was set on every
|
|
// operation (meaning the operations will not be represented in the OpenAPI document).
|
|
//
|
|
// This allows a human reading the OpenAPI document to notice that, yes, a path handler does exist,
|
|
// even though it was not able to contribute actual OpenAPI operations.
|
|
forceUnpublished = true
|
|
paths = []string{p.Pattern}
|
|
} else {
|
|
return err
|
|
}
|
|
}
|
|
|
|
for pathIndex, 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)
|
|
pi.DisplayAttrs = withoutOperationHints(p.DisplayAttrs)
|
|
|
|
// 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
|
|
|
|
if field == nil {
|
|
continue
|
|
}
|
|
|
|
if field.Query {
|
|
location = "query"
|
|
required = false
|
|
}
|
|
|
|
t := convertType(field.Type)
|
|
p := OASParameter{
|
|
Name: name,
|
|
Description: cleanString(field.Description),
|
|
In: location,
|
|
Schema: &OASSchema{
|
|
Type: t.baseType,
|
|
Pattern: t.pattern,
|
|
Enum: field.AllowedValues,
|
|
Default: field.Default,
|
|
DisplayAttrs: withoutOperationHints(field.DisplayAttrs),
|
|
},
|
|
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.
|
|
var listOperation *OASOperation
|
|
for opType, opHandler := range operations {
|
|
props := opHandler.Properties()
|
|
if props.Unpublished || forceUnpublished {
|
|
continue
|
|
}
|
|
|
|
if opType == logical.CreateOperation {
|
|
pi.CreateSupported = true
|
|
|
|
// If both Create and Update are defined, only process Update.
|
|
if operations[logical.UpdateOperation] != nil {
|
|
continue
|
|
}
|
|
}
|
|
|
|
op := NewOASOperation()
|
|
|
|
operationID := constructOperationID(
|
|
path,
|
|
pathIndex,
|
|
p.DisplayAttrs,
|
|
opType,
|
|
props.DisplayAttrs,
|
|
requestResponsePrefix,
|
|
)
|
|
|
|
op.Summary = props.Summary
|
|
op.Description = props.Description
|
|
op.Deprecated = props.Deprecated
|
|
op.OperationID = operationID
|
|
|
|
// 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),
|
|
Required: make([]string, 0),
|
|
}
|
|
|
|
for name, field := range bodyFields {
|
|
// Removing this field from the spec as it is deprecated in favor of using "sha256"
|
|
// The duplicate sha_256 and sha256 in these paths cause issues with codegen
|
|
if name == "sha_256" && strings.Contains(path, "plugins/catalog/") {
|
|
continue
|
|
}
|
|
|
|
openapiField := convertType(field.Type)
|
|
if field.Required {
|
|
s.Required = append(s.Required, name)
|
|
}
|
|
|
|
p := OASSchema{
|
|
Type: openapiField.baseType,
|
|
Description: cleanString(field.Description),
|
|
Format: openapiField.format,
|
|
Pattern: openapiField.pattern,
|
|
Enum: field.AllowedValues,
|
|
Default: field.Default,
|
|
Deprecated: field.Deprecated,
|
|
DisplayAttrs: withoutOperationHints(field.DisplayAttrs),
|
|
}
|
|
if openapiField.baseType == "array" {
|
|
p.Items = &OASSchema{
|
|
Type: openapiField.items,
|
|
}
|
|
}
|
|
s.Properties[name] = &p
|
|
}
|
|
|
|
// Make the ordering deterministic, so that the generated OpenAPI spec document, observed over several
|
|
// versions, doesn't contain spurious non-semantic changes.
|
|
sort.Strings(s.Required)
|
|
|
|
// 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 {
|
|
requestName := hyphenatedToTitleCase(operationID) + "Request"
|
|
doc.Components.Schemas[requestName] = s
|
|
op.RequestBody = &OASRequestBody{
|
|
Required: true,
|
|
Content: OASContent{
|
|
"application/json": &OASMediaTypeObject{
|
|
Schema: &OASSchema{Ref: fmt.Sprintf("#/components/schemas/%s", requestName)},
|
|
},
|
|
},
|
|
}
|
|
}
|
|
}
|
|
|
|
// LIST is represented as GET with a `list` query parameter. Code later on in this function will assign
|
|
// list operations to a path with an extra trailing slash, ensuring they do not collide with read
|
|
// operations.
|
|
if opType == logical.ListOperation {
|
|
op.Parameters = append(op.Parameters, OASParameter{
|
|
Name: "list",
|
|
Description: "Must be set to `true`",
|
|
Required: true,
|
|
In: "query",
|
|
Schema: &OASSchema{Type: "string", Enum: []interface{}{"true"}},
|
|
})
|
|
}
|
|
|
|
// Add tags based on backend type
|
|
var tags []string
|
|
switch backend.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 := cleanResponse(resp.Example)
|
|
|
|
// Only one example per media type is allowed, so first one wins
|
|
if _, ok := content[mediaType]; !ok {
|
|
content[mediaType] = &OASMediaTypeObject{
|
|
Schema: &OASSchema{
|
|
Example: cr,
|
|
},
|
|
}
|
|
}
|
|
}
|
|
|
|
responseSchema := &OASSchema{
|
|
Type: "object",
|
|
Properties: make(map[string]*OASSchema),
|
|
}
|
|
|
|
for name, field := range resp.Fields {
|
|
openapiField := convertType(field.Type)
|
|
p := OASSchema{
|
|
Type: openapiField.baseType,
|
|
Description: cleanString(field.Description),
|
|
Format: openapiField.format,
|
|
Pattern: openapiField.pattern,
|
|
Enum: field.AllowedValues,
|
|
Default: field.Default,
|
|
Deprecated: field.Deprecated,
|
|
DisplayAttrs: withoutOperationHints(field.DisplayAttrs),
|
|
}
|
|
if openapiField.baseType == "array" {
|
|
p.Items = &OASSchema{
|
|
Type: openapiField.items,
|
|
}
|
|
}
|
|
responseSchema.Properties[name] = &p
|
|
}
|
|
|
|
if len(resp.Fields) != 0 {
|
|
responseName := hyphenatedToTitleCase(operationID) + "Response"
|
|
doc.Components.Schemas[responseName] = responseSchema
|
|
content = OASContent{
|
|
"application/json": &OASMediaTypeObject{
|
|
Schema: &OASSchema{Ref: fmt.Sprintf("#/components/schemas/%s", responseName)},
|
|
},
|
|
}
|
|
}
|
|
}
|
|
|
|
op.Responses[code] = &OASResponse{
|
|
Description: description,
|
|
Content: content,
|
|
}
|
|
}
|
|
|
|
switch opType {
|
|
case logical.CreateOperation, logical.UpdateOperation:
|
|
pi.Post = op
|
|
case logical.ReadOperation:
|
|
pi.Get = op
|
|
case logical.DeleteOperation:
|
|
pi.Delete = op
|
|
case logical.ListOperation:
|
|
listOperation = op
|
|
}
|
|
}
|
|
|
|
// The conventions enforced by the Vault HTTP routing code make it impossible to match a path with a trailing
|
|
// slash to anything other than a ListOperation. Catch mistakes in path definition, to enforce that if both of
|
|
// the two following blocks of code (non-list, and list) write an OpenAPI path to the output document, then the
|
|
// first one will definitely not have a trailing slash.
|
|
originalPathHasTrailingSlash := strings.HasSuffix(path, "/")
|
|
if originalPathHasTrailingSlash && (pi.Get != nil || pi.Post != nil || pi.Delete != nil) {
|
|
backend.Logger().Warn(
|
|
"OpenAPI spec generation: discarding impossible-to-invoke non-list operations from path with "+
|
|
"required trailing slash; this is a bug in the backend code", "path", path)
|
|
pi.Get = nil
|
|
pi.Post = nil
|
|
pi.Delete = nil
|
|
}
|
|
|
|
// Write the regular, non-list, OpenAPI path to the OpenAPI document, UNLESS we generated a ListOperation, and
|
|
// NO OTHER operation types. In that fairly common case (there are lots of list-only endpoints), we avoid
|
|
// writing a redundant OpenAPI path for (e.g.) "auth/token/accessors" with no operations, only to then write
|
|
// one for "auth/token/accessors/" immediately below.
|
|
//
|
|
// On the other hand, we do still write the OpenAPI path here if we generated ZERO operation types - this serves
|
|
// to provide documentation to a human that an endpoint exists, even if it has no invokable OpenAPI operations.
|
|
// Examples of this include kv-v2's ".*" endpoint (regex cannot be translated to OpenAPI parameters), and the
|
|
// auth/oci/login endpoint (implements ResolveRoleOperation only, only callable from inside Vault).
|
|
if listOperation == nil || pi.Get != nil || pi.Post != nil || pi.Delete != nil {
|
|
openAPIPath := "/" + path
|
|
if doc.Paths[openAPIPath] != nil {
|
|
backend.Logger().Warn(
|
|
"OpenAPI spec generation: multiple framework.Path instances generated the same path; "+
|
|
"last processed wins", "path", openAPIPath)
|
|
}
|
|
doc.Paths[openAPIPath] = &pi
|
|
}
|
|
|
|
// If there is a ListOperation, write it to a separate OpenAPI path in the document.
|
|
if listOperation != nil {
|
|
// Append a slash here to disambiguate from the path written immediately above.
|
|
// However, if the path already contains a trailing slash, we want to avoid doubling it, and it is
|
|
// guaranteed (through the interaction of logic in the last two blocks) that the block immediately above
|
|
// will NOT have written a path to the OpenAPI document.
|
|
if !originalPathHasTrailingSlash {
|
|
path += "/"
|
|
}
|
|
|
|
listPathItem := OASPathItem{
|
|
Description: pi.Description,
|
|
Parameters: pi.Parameters,
|
|
DisplayAttrs: pi.DisplayAttrs,
|
|
|
|
// Since the path may now have an extra slash on the end, we need to recalculate the special path
|
|
// matches, as the sudo or unauthenticated status may be changed as a result!
|
|
Sudo: specialPathMatch(path, sudoPaths),
|
|
Unauthenticated: specialPathMatch(path, unauthPaths),
|
|
|
|
Get: listOperation,
|
|
}
|
|
|
|
openAPIPath := "/" + path
|
|
if doc.Paths[openAPIPath] != nil {
|
|
backend.Logger().Warn(
|
|
"OpenAPI spec generation: multiple framework.Path instances generated the same path; "+
|
|
"last processed wins", "path", openAPIPath)
|
|
}
|
|
doc.Paths[openAPIPath] = &listPathItem
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// specialPathMatch checks whether the given path matches one of the special
|
|
// paths, taking into account * and + wildcards (e.g. foo/+/bar/*)
|
|
func specialPathMatch(path string, specialPaths []string) bool {
|
|
// pathMatchesByParts determines if the path matches the special path's
|
|
// pattern, accounting for the '+' and '*' wildcards
|
|
pathMatchesByParts := func(pathParts []string, specialPathParts []string) bool {
|
|
if len(pathParts) < len(specialPathParts) {
|
|
return false
|
|
}
|
|
for i := 0; i < len(specialPathParts); i++ {
|
|
var (
|
|
part = pathParts[i]
|
|
pattern = specialPathParts[i]
|
|
)
|
|
if pattern == "+" {
|
|
continue
|
|
}
|
|
if pattern == "*" {
|
|
return true
|
|
}
|
|
if strings.HasSuffix(pattern, "*") && strings.HasPrefix(part, pattern[0:len(pattern)-1]) {
|
|
return true
|
|
}
|
|
if pattern != part {
|
|
return false
|
|
}
|
|
}
|
|
return len(pathParts) == len(specialPathParts)
|
|
}
|
|
|
|
pathParts := strings.Split(path, "/")
|
|
|
|
for _, sp := range specialPaths {
|
|
// exact match
|
|
if sp == path {
|
|
return true
|
|
}
|
|
|
|
// match *
|
|
if strings.HasSuffix(sp, "*") && strings.HasPrefix(path, sp[0:len(sp)-1]) {
|
|
return true
|
|
}
|
|
|
|
// match +
|
|
if strings.Contains(sp, "+") && pathMatchesByParts(pathParts, strings.Split(sp, "/")) {
|
|
return true
|
|
}
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
// constructOperationID joins the given inputs into a hyphen-separated
|
|
// lower-case operation id, which is also used as a prefix for request and
|
|
// response names.
|
|
//
|
|
// The OperationPrefix / -Verb / -Suffix found in display attributes will be
|
|
// used, if provided. Otherwise, the function falls back to using the path and
|
|
// the operation.
|
|
//
|
|
// Examples of generated operation identifiers:
|
|
// - kvv2-write
|
|
// - kvv2-read
|
|
// - google-cloud-login
|
|
// - google-cloud-write-role
|
|
func constructOperationID(
|
|
path string,
|
|
pathIndex int,
|
|
pathAttributes *DisplayAttributes,
|
|
operation logical.Operation,
|
|
operationAttributes *DisplayAttributes,
|
|
defaultPrefix string,
|
|
) string {
|
|
var (
|
|
prefix string
|
|
verb string
|
|
suffix string
|
|
)
|
|
|
|
if operationAttributes != nil {
|
|
prefix = operationAttributes.OperationPrefix
|
|
verb = operationAttributes.OperationVerb
|
|
suffix = operationAttributes.OperationSuffix
|
|
}
|
|
|
|
if pathAttributes != nil {
|
|
if prefix == "" {
|
|
prefix = pathAttributes.OperationPrefix
|
|
}
|
|
if verb == "" {
|
|
verb = pathAttributes.OperationVerb
|
|
}
|
|
if suffix == "" {
|
|
suffix = pathAttributes.OperationSuffix
|
|
}
|
|
}
|
|
|
|
// A single suffix string can contain multiple pipe-delimited strings. To
|
|
// determine the actual suffix, we attempt to match it by the index of the
|
|
// paths returned from `expandPattern(...)`. For example:
|
|
//
|
|
// pki/
|
|
// Pattern: "keys/generate/(internal|exported|kms)",
|
|
// DisplayAttrs: {
|
|
// ...
|
|
// OperationSuffix: "internal-key|exported-key|kms-key",
|
|
// },
|
|
//
|
|
// will expand into three paths and corresponding suffixes:
|
|
//
|
|
// path 0: "keys/generate/internal" suffix: internal-key
|
|
// path 1: "keys/generate/exported" suffix: exported-key
|
|
// path 2: "keys/generate/kms" suffix: kms-key
|
|
//
|
|
pathIndexOutOfRange := false
|
|
|
|
if suffixes := strings.Split(suffix, "|"); len(suffixes) > 1 || pathIndex > 0 {
|
|
// if the index is out of bounds, fall back to the old logic
|
|
if pathIndex >= len(suffixes) {
|
|
suffix = ""
|
|
pathIndexOutOfRange = true
|
|
} else {
|
|
suffix = suffixes[pathIndex]
|
|
}
|
|
}
|
|
|
|
// a helper that hyphenates & lower-cases the slice except the empty elements
|
|
toLowerHyphenate := func(parts []string) string {
|
|
filtered := make([]string, 0, len(parts))
|
|
for _, e := range parts {
|
|
if e != "" {
|
|
filtered = append(filtered, e)
|
|
}
|
|
}
|
|
return strings.ToLower(strings.Join(filtered, "-"))
|
|
}
|
|
|
|
// fall back to using the path + operation to construct the operation id
|
|
var (
|
|
needPrefix = prefix == "" && verb == ""
|
|
needVerb = verb == ""
|
|
needSuffix = suffix == "" && (verb == "" || pathIndexOutOfRange)
|
|
)
|
|
|
|
if needPrefix {
|
|
prefix = defaultPrefix
|
|
}
|
|
|
|
if needVerb {
|
|
if operation == logical.UpdateOperation {
|
|
verb = "write"
|
|
} else {
|
|
verb = string(operation)
|
|
}
|
|
}
|
|
|
|
if needSuffix {
|
|
suffix = toLowerHyphenate(nonWordRe.Split(path, -1))
|
|
}
|
|
|
|
return toLowerHyphenate([]string{prefix, verb, suffix})
|
|
}
|
|
|
|
// 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, error) {
|
|
// Happily, the Go regexp library exposes its underlying "parse to AST" functionality, so we can rely on that to do
|
|
// the hard work of interpreting the regexp syntax.
|
|
rx, err := syntax.Parse(pattern, syntax.Perl)
|
|
if err != nil {
|
|
// This should be impossible to reach, since regexps have previously been compiled with MustCompile in
|
|
// Backend.init.
|
|
panic(err)
|
|
}
|
|
|
|
paths, err := collectPathsFromRegexpAST(rx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return paths, nil
|
|
}
|
|
|
|
type pathCollector struct {
|
|
strings.Builder
|
|
conditionalSlashAppendedAtLength int
|
|
}
|
|
|
|
// collectPathsFromRegexpAST performs a depth-first recursive walk through a regexp AST, collecting an OpenAPI-style
|
|
// path as it goes.
|
|
//
|
|
// Each time it encounters alternation (a|b) or an optional part (a?), it forks its processing to produce additional
|
|
// results, to account for each possibility. Note: This does mean that an input pattern with lots of these regexp
|
|
// features can produce a lot of different OpenAPI endpoints. At the time of writing, the most complex known example is
|
|
//
|
|
// "issuer/" + framework.GenericNameRegex(issuerRefParam) + "/crl(/pem|/der|/delta(/pem|/der)?)?"
|
|
//
|
|
// in the PKI secrets engine which expands to 6 separate paths.
|
|
//
|
|
// Each named capture group - i.e. (?P<name>something here) - is replaced with an OpenAPI parameter - i.e. {name} - and
|
|
// the subtree of regexp AST inside the parameter is completely skipped.
|
|
func collectPathsFromRegexpAST(rx *syntax.Regexp) ([]string, error) {
|
|
pathCollectors, err := collectPathsFromRegexpASTInternal(rx, []*pathCollector{{}})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
paths := make([]string, 0, len(pathCollectors))
|
|
for _, collector := range pathCollectors {
|
|
if collector.conditionalSlashAppendedAtLength != collector.Len() {
|
|
paths = append(paths, collector.String())
|
|
}
|
|
}
|
|
return paths, nil
|
|
}
|
|
|
|
var errUnsupportableRegexpOperationForOpenAPI = errors.New("path regexp uses an operation that cannot be translated to an OpenAPI pattern")
|
|
|
|
func collectPathsFromRegexpASTInternal(rx *syntax.Regexp, appendingTo []*pathCollector) ([]*pathCollector, error) {
|
|
var err error
|
|
|
|
// Depending on the type of this regexp AST node (its Op, i.e. operation), figure out whether it contributes any
|
|
// characters to the URL path, and whether we need to recurse through child AST nodes.
|
|
//
|
|
// Each element of the appendingTo slice tracks a separate path, defined by the alternatives chosen when traversing
|
|
// the | and ? conditional regexp features, and new elements are added as each of these features are traversed.
|
|
//
|
|
// To share this slice across multiple recursive calls of this function, it is passed down as a parameter to each
|
|
// recursive call, potentially modified throughout this switch block, and passed back up as a return value at the
|
|
// end of this function - the parent call uses the return value to update its own local variable.
|
|
switch rx.Op {
|
|
|
|
// These AST operations are leaf nodes (no children), that match zero characters, so require no processing at all
|
|
case syntax.OpEmptyMatch: // e.g. (?:)
|
|
case syntax.OpBeginLine: // i.e. ^ when (?m)
|
|
case syntax.OpEndLine: // i.e. $ when (?m)
|
|
case syntax.OpBeginText: // i.e. \A, or ^ when (?-m)
|
|
case syntax.OpEndText: // i.e. \z, or $ when (?-m)
|
|
case syntax.OpWordBoundary: // i.e. \b
|
|
case syntax.OpNoWordBoundary: // i.e. \B
|
|
|
|
// OpConcat simply represents multiple parts of the pattern appearing one after the other, so just recurse through
|
|
// those pieces.
|
|
case syntax.OpConcat:
|
|
for _, child := range rx.Sub {
|
|
appendingTo, err = collectPathsFromRegexpASTInternal(child, appendingTo)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
// OpLiteral is a literal string in the pattern - append it to the paths we are building.
|
|
case syntax.OpLiteral:
|
|
for _, collector := range appendingTo {
|
|
collector.WriteString(string(rx.Rune))
|
|
}
|
|
|
|
// OpAlternate, i.e. a|b, means we clone all of the pathCollector instances we are currently accumulating paths
|
|
// into, and independently recurse through each alternate option.
|
|
case syntax.OpAlternate: // i.e |
|
|
var totalAppendingTo []*pathCollector
|
|
lastIndex := len(rx.Sub) - 1
|
|
for index, child := range rx.Sub {
|
|
var childAppendingTo []*pathCollector
|
|
if index == lastIndex {
|
|
// Optimization: last time through this loop, we can simply re-use the existing set of pathCollector
|
|
// instances, as we no longer need to preserve them unmodified to make further copies of.
|
|
childAppendingTo = appendingTo
|
|
} else {
|
|
for _, collector := range appendingTo {
|
|
newCollector := new(pathCollector)
|
|
newCollector.WriteString(collector.String())
|
|
newCollector.conditionalSlashAppendedAtLength = collector.conditionalSlashAppendedAtLength
|
|
childAppendingTo = append(childAppendingTo, newCollector)
|
|
}
|
|
}
|
|
childAppendingTo, err = collectPathsFromRegexpASTInternal(child, childAppendingTo)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
totalAppendingTo = append(totalAppendingTo, childAppendingTo...)
|
|
}
|
|
appendingTo = totalAppendingTo
|
|
|
|
// OpQuest, i.e. a?, is much like an alternation between exactly two options, one of which is the empty string.
|
|
case syntax.OpQuest:
|
|
child := rx.Sub[0]
|
|
var childAppendingTo []*pathCollector
|
|
for _, collector := range appendingTo {
|
|
newCollector := new(pathCollector)
|
|
newCollector.WriteString(collector.String())
|
|
newCollector.conditionalSlashAppendedAtLength = collector.conditionalSlashAppendedAtLength
|
|
childAppendingTo = append(childAppendingTo, newCollector)
|
|
}
|
|
childAppendingTo, err = collectPathsFromRegexpASTInternal(child, childAppendingTo)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
appendingTo = append(appendingTo, childAppendingTo...)
|
|
|
|
// Many Vault path patterns end with `/?` to accept paths that end with or without a slash. Our current
|
|
// convention for generating the OpenAPI is to strip away these slashes. To do that, this very special case
|
|
// detects when we just appended a single conditional slash, and records the length of the path at this point,
|
|
// so we can later discard this path variant, if nothing else is appended to it later.
|
|
if child.Op == syntax.OpLiteral && string(child.Rune) == "/" {
|
|
for _, collector := range childAppendingTo {
|
|
collector.conditionalSlashAppendedAtLength = collector.Len()
|
|
}
|
|
}
|
|
|
|
// OpCapture, i.e. ( ) or (?P<name> ), a capturing group
|
|
case syntax.OpCapture:
|
|
if rx.Name == "" {
|
|
// In Vault, an unnamed capturing group is not actually used for capturing.
|
|
// We treat it exactly the same as OpConcat.
|
|
for _, child := range rx.Sub {
|
|
appendingTo, err = collectPathsFromRegexpASTInternal(child, appendingTo)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
} else {
|
|
// A named capturing group is replaced with the OpenAPI parameter syntax, and the regexp inside the group
|
|
// is NOT added to the OpenAPI path.
|
|
for _, builder := range appendingTo {
|
|
builder.WriteRune('{')
|
|
builder.WriteString(rx.Name)
|
|
builder.WriteRune('}')
|
|
}
|
|
}
|
|
|
|
// Any other kind of operation is a problem, and will trigger an error, resulting in the pattern being left out of
|
|
// the OpenAPI entirely - that's better than generating a path which is incorrect.
|
|
//
|
|
// The Op types we expect to hit the default condition are:
|
|
//
|
|
// OpCharClass - i.e. [something]
|
|
// OpAnyCharNotNL - i.e. .
|
|
// OpAnyChar - i.e. (?s:.)
|
|
// OpStar - i.e. *
|
|
// OpPlus - i.e. +
|
|
// OpRepeat - i.e. {N}, {N,M}, etc.
|
|
//
|
|
// In any of these conditions, there is no sensible translation of the path to OpenAPI syntax. (Note, this only
|
|
// applies to these appearing outside of a named capture group, otherwise they are handled in the previous case.)
|
|
//
|
|
// At the time of writing, the only pattern in the builtin Vault plugins that hits this codepath is the ".*"
|
|
// pattern in the KVv2 secrets engine, which is not a valid path, but rather, is a catch-all used to implement
|
|
// custom error handling behaviour to guide users who attempt to treat a KVv2 as a KVv1. It is already marked as
|
|
// Unpublished, so is withheld from the OpenAPI anyway.
|
|
//
|
|
// For completeness, one other Op type exists, OpNoMatch, which is never generated by syntax.Parse - only by
|
|
// subsequent Simplify in preparation to Compile, which is not used here.
|
|
default:
|
|
return nil, errUnsupportableRegexpOperationForOpenAPI
|
|
}
|
|
|
|
return appendingTo, nil
|
|
}
|
|
|
|
// 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 = "integer"
|
|
case TypeInt64:
|
|
ret.baseType = "integer"
|
|
ret.format = "int64"
|
|
case TypeDurationSecond, TypeSignedDurationSecond:
|
|
ret.baseType = "string"
|
|
ret.format = "duration"
|
|
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 = "integer"
|
|
case TypeTime:
|
|
ret.baseType = "string"
|
|
ret.format = "date-time"
|
|
case TypeFloat:
|
|
ret.baseType = "number"
|
|
ret.format = "float"
|
|
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 {
|
|
if field.Query {
|
|
pathFields[name] = field
|
|
} else {
|
|
bodyFields[name] = field
|
|
}
|
|
}
|
|
}
|
|
|
|
return pathFields, bodyFields
|
|
}
|
|
|
|
// withoutOperationHints returns a copy of the given DisplayAttributes without
|
|
// OperationPrefix / OperationVerb / OperationSuffix since we don't need these
|
|
// fields in the final output.
|
|
func withoutOperationHints(in *DisplayAttributes) *DisplayAttributes {
|
|
if in == nil {
|
|
return nil
|
|
}
|
|
|
|
copy := *in
|
|
|
|
copy.OperationPrefix = ""
|
|
copy.OperationVerb = ""
|
|
copy.OperationSuffix = ""
|
|
|
|
// return nil if all fields are empty to avoid empty JSON objects
|
|
if copy == (DisplayAttributes{}) {
|
|
return nil
|
|
}
|
|
|
|
return ©
|
|
}
|
|
|
|
func hyphenatedToTitleCase(in string) string {
|
|
var b strings.Builder
|
|
|
|
title := cases.Title(language.English, cases.NoLower)
|
|
|
|
for _, word := range strings.Split(in, "-") {
|
|
b.WriteString(title.String(word))
|
|
}
|
|
|
|
return b.String()
|
|
}
|
|
|
|
// 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"`
|
|
Headers map[string][]string `json:"headers,omitempty"`
|
|
}
|
|
|
|
func cleanResponse(resp *logical.Response) *cleanedResponse {
|
|
return &cleanedResponse{
|
|
Secret: resp.Secret,
|
|
Auth: resp.Auth,
|
|
Data: resp.Data,
|
|
Redirect: resp.Redirect,
|
|
Warnings: resp.Warnings,
|
|
WrapInfo: resp.WrapInfo,
|
|
Headers: resp.Headers,
|
|
}
|
|
}
|
|
|
|
// CreateOperationIDs generates unique operationIds for all paths/methods.
|
|
// The transform will convert path/method into camelcase. e.g.:
|
|
//
|
|
// /sys/tools/random/{urlbytes} -> postSysToolsRandomUrlbytes
|
|
//
|
|
// In the unlikely case of a duplicate ids, a numeric suffix is added:
|
|
//
|
|
// postSysToolsRandomUrlbytes_2
|
|
//
|
|
// An optional user-provided suffix ("context") may also be appended.
|
|
//
|
|
// Deprecated: operationID's are now populated using `constructOperationID`.
|
|
// This function is here for backwards compatibility with older plugins.
|
|
func (d *OASDocument) CreateOperationIDs(context string) {
|
|
opIDCount := make(map[string]int)
|
|
var paths []string
|
|
|
|
// traverse paths in a stable order to ensure stable output
|
|
for path := range d.Paths {
|
|
paths = append(paths, path)
|
|
}
|
|
sort.Strings(paths)
|
|
|
|
for _, path := range paths {
|
|
pi := d.Paths[path]
|
|
for _, method := range []string{"get", "post", "delete"} {
|
|
var oasOperation *OASOperation
|
|
switch method {
|
|
case "get":
|
|
oasOperation = pi.Get
|
|
case "post":
|
|
oasOperation = pi.Post
|
|
case "delete":
|
|
oasOperation = pi.Delete
|
|
}
|
|
|
|
if oasOperation == nil {
|
|
continue
|
|
}
|
|
|
|
if oasOperation.OperationID != "" {
|
|
continue
|
|
}
|
|
|
|
// Discard "_mount_path" from any {thing_mount_path} parameters
|
|
path = strings.Replace(path, "_mount_path", "", 1)
|
|
|
|
// Space-split on non-words, title case everything, recombine
|
|
opID := nonWordRe.ReplaceAllString(strings.ToLower(path), " ")
|
|
opID = strings.Title(opID)
|
|
opID = method + strings.ReplaceAll(opID, " ", "")
|
|
|
|
// deduplicate operationIds. This is a safeguard, since generated IDs should
|
|
// already be unique given our current path naming conventions.
|
|
opIDCount[opID]++
|
|
if opIDCount[opID] > 1 {
|
|
opID = fmt.Sprintf("%s_%d", opID, opIDCount[opID])
|
|
}
|
|
|
|
if context != "" {
|
|
opID += "_" + context
|
|
}
|
|
|
|
oasOperation.OperationID = opID
|
|
}
|
|
}
|
|
}
|