open-vault/sdk/framework/openapi.go

787 lines
25 KiB
Go

package framework
import (
"fmt"
"reflect"
"regexp"
"sort"
"strconv"
"strings"
log "github.com/hashicorp/go-hclog"
"github.com/hashicorp/vault/sdk/helper/wrapping"
"github.com/hashicorp/vault/sdk/logical"
"github.com/hashicorp/vault/sdk/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),
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"`
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 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 (
altFieldsGroupRe = regexp.MustCompile(`\(\?P<\w+>\w+(\|\w+)+\)`) // Match named groups that limit options, e.g. "(?<foo>a|b|c)"
altFieldsRe = regexp.MustCompile(`\w+(\|\w+)+`) // Match an options set, e.g. "a|b|c"
altRe = regexp.MustCompile(`\((.*)\|(.*)\)`) // Capture alternation elements, e.g. "(raw/?$|raw/(?P<path>.+))"
altRootsRe = regexp.MustCompile(`^\(([\w\-_]+(?:\|[\w\-_]+)+)\)(/.*)$`) // Pattern starting with alts, e.g. "(root1|root2)/(?P<name>regex)"
cleanCharsRe = regexp.MustCompile("[()^$?]") // Set of regex characters that will be stripped during cleaning
cleanSuffixRe = regexp.MustCompile(`/\?\$?$`) // Path suffix patterns that will be stripped during cleaning
nonWordRe = regexp.MustCompile(`[^\w]+`) // Match a sequence of non-word characters
pathFieldsRe = regexp.MustCompile(`{(\w+)}`) // Capture OpenAPI-style named parameters, e.g. "lookup/{urltoken}",
reqdRe = regexp.MustCompile(`\(?\?P<(\w+)>[^)]*\)?`) // Capture required parameters, e.g. "(?P<name>regex)"
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, genericMountPaths bool, doc *OASDocument) error {
for _, p := range backend.Paths {
if err := documentPath(p, backend.SpecialPaths(), requestResponsePrefix, genericMountPaths, 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, genericMountPaths bool, 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.
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)
pi.DisplayAttrs = 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)
if genericMountPaths && requestResponsePrefix != "system" && requestResponsePrefix != "identity" {
// Add mount path as a parameter
p := OASParameter{
Name: "mountPath",
Description: "Path that the backend was mounted at",
In: "path",
Schema: &OASSchema{
Type: "string",
},
Required: true,
}
pi.Parameters = append(pi.Parameters, p)
}
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: 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 {
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),
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: field.DisplayAttrs,
}
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 {
requestName := constructRequestName(requestResponsePrefix, path)
doc.Components.Schemas[requestName] = s
op.RequestBody = &OASRequestBody{
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,
},
}
}
}
}
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
}
// constructRequestName joins the given prefix with the path elements into a
// CamelCaseRequest string.
//
// For example, prefix="kv" & path=/config/lease/{name} => KvConfigLeaseRequest
func constructRequestName(requestResponsePrefix string, path string) string {
var b strings.Builder
b.WriteString(strings.Title(requestResponsePrefix))
// split the path by / _ - separators
for _, token := range strings.FieldsFunc(path, func(r rune) bool {
return r == '/' || r == '_' || r == '-'
}) {
// exclude request fields
if !strings.ContainsAny(token, "{}") {
b.WriteString(strings.Title(token))
}
}
b.WriteString("Request")
return b.String()
}
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
// Determine if the pattern starts with an alternation for multiple roots
// example (root1|root2)/(?P<name>regex) -> match['(root1|root2)/(?P<name>regex)','root1|root2','/(?P<name>regex)']
match := altRootsRe.FindStringSubmatch(pattern)
if len(match) == 3 {
var expandedRoots []string
for _, root := range strings.Split(match[1], "|") {
expandedRoots = append(expandedRoots, expandPattern(root+match[2])...)
}
return expandedRoots
}
// 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.ReplaceAll(pattern, regexToRemove, "")
// Simplify named fields that have limited options, e.g. (?P<foo>a|b|c) -> (<P<foo>.+)
pattern = altFieldsGroupRe.ReplaceAllStringFunc(pattern, func(s string) string {
return altFieldsRe.ReplaceAllString(s, ".+")
})
// 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 = "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
}
// 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.
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
}
// 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
}
}
}