open-vault/sdk/framework/openapi.go
hc-github-team-secure-vault-core 96596f255b
backport of commit e65d295c8809f4cc61b63bbc03293586c71c5483 (#20906)
Co-authored-by: Max Bowsher <maxbowsher@gmail.com>
2023-05-31 19:41:39 +00:00

1116 lines
35 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"`
DisplayNavigation bool `json:"x-vault-displayNavigation,omitempty" mapstructure:"x-vault-displayNavigation"`
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.SpecialPaths(), requestResponsePrefix, 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, requestResponsePrefix string, 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 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.
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
}
}
// If both List and Read are defined, only process Read.
if opType == logical.ListOperation && operations[logical.ReadOperation] != 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.
if opType == logical.ListOperation {
// Only accepts List (due to the above skipping of ListOperations that also have ReadOperations)
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"}},
})
} else if opType == logical.ReadOperation && operations[logical.ListOperation] != nil {
// Accepts both Read and List
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 := 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, logical.ListOperation:
pi.Get = op
case logical.DeleteOperation:
pi.Delete = op
}
}
doc.Paths["/"+path] = &pi
}
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 = "integer"
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 = "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 &copy
}
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
}
}
}