github.com/hashicorp/vault/sdk@v0.13.0/framework/openapi.go (about) 1 package framework 2 3 import ( 4 "errors" 5 "fmt" 6 "reflect" 7 "regexp" 8 "regexp/syntax" 9 "sort" 10 "strconv" 11 "strings" 12 13 log "github.com/hashicorp/go-hclog" 14 "github.com/hashicorp/vault/sdk/helper/wrapping" 15 "github.com/hashicorp/vault/sdk/logical" 16 "github.com/mitchellh/mapstructure" 17 "golang.org/x/text/cases" 18 "golang.org/x/text/language" 19 ) 20 21 // OpenAPI specification (OAS): https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.2.md 22 const OASVersion = "3.0.2" 23 24 // NewOASDocument returns an empty OpenAPI document. 25 func NewOASDocument(version string) *OASDocument { 26 return &OASDocument{ 27 Version: OASVersion, 28 Info: OASInfo{ 29 Title: "HashiCorp Vault API", 30 Description: "HTTP API that gives you full access to Vault. All API routes are prefixed with `/v1/`.", 31 Version: version, 32 License: OASLicense{ 33 Name: "Mozilla Public License 2.0", 34 URL: "https://www.mozilla.org/en-US/MPL/2.0", 35 }, 36 }, 37 Paths: make(map[string]*OASPathItem), 38 Components: OASComponents{ 39 Schemas: make(map[string]*OASSchema), 40 }, 41 } 42 } 43 44 // NewOASDocumentFromMap builds an OASDocument from an existing map version of a document. 45 // If a document has been decoded from JSON or received from a plugin, it will be as a map[string]interface{} 46 // and needs special handling beyond the default mapstructure decoding. 47 func NewOASDocumentFromMap(input map[string]interface{}) (*OASDocument, error) { 48 // The Responses map uses integer keys (the response code), but once translated into JSON 49 // (e.g. during the plugin transport) these become strings. mapstructure will not coerce these back 50 // to integers without a custom decode hook. 51 decodeHook := func(src reflect.Type, tgt reflect.Type, inputRaw interface{}) (interface{}, error) { 52 // Only alter data if: 53 // 1. going from string to int 54 // 2. string represent an int in status code range (100-599) 55 if src.Kind() == reflect.String && tgt.Kind() == reflect.Int { 56 if input, ok := inputRaw.(string); ok { 57 if intval, err := strconv.Atoi(input); err == nil { 58 if intval >= 100 && intval < 600 { 59 return intval, nil 60 } 61 } 62 } 63 } 64 return inputRaw, nil 65 } 66 67 doc := new(OASDocument) 68 69 config := &mapstructure.DecoderConfig{ 70 DecodeHook: decodeHook, 71 Result: doc, 72 } 73 74 decoder, err := mapstructure.NewDecoder(config) 75 if err != nil { 76 return nil, err 77 } 78 79 if err := decoder.Decode(input); err != nil { 80 return nil, err 81 } 82 83 return doc, nil 84 } 85 86 type OASDocument struct { 87 Version string `json:"openapi" mapstructure:"openapi"` 88 Info OASInfo `json:"info"` 89 Paths map[string]*OASPathItem `json:"paths"` 90 Components OASComponents `json:"components"` 91 } 92 93 type OASComponents struct { 94 Schemas map[string]*OASSchema `json:"schemas"` 95 } 96 97 type OASInfo struct { 98 Title string `json:"title"` 99 Description string `json:"description"` 100 Version string `json:"version"` 101 License OASLicense `json:"license"` 102 } 103 104 type OASLicense struct { 105 Name string `json:"name"` 106 URL string `json:"url"` 107 } 108 109 type OASPathItem struct { 110 Description string `json:"description,omitempty"` 111 Parameters []OASParameter `json:"parameters,omitempty"` 112 Sudo bool `json:"x-vault-sudo,omitempty" mapstructure:"x-vault-sudo"` 113 Unauthenticated bool `json:"x-vault-unauthenticated,omitempty" mapstructure:"x-vault-unauthenticated"` 114 CreateSupported bool `json:"x-vault-createSupported,omitempty" mapstructure:"x-vault-createSupported"` 115 DisplayAttrs *DisplayAttributes `json:"x-vault-displayAttrs,omitempty" mapstructure:"x-vault-displayAttrs"` 116 117 Get *OASOperation `json:"get,omitempty"` 118 Post *OASOperation `json:"post,omitempty"` 119 Delete *OASOperation `json:"delete,omitempty"` 120 } 121 122 // NewOASOperation creates an empty OpenAPI Operations object. 123 func NewOASOperation() *OASOperation { 124 return &OASOperation{ 125 Responses: make(map[int]*OASResponse), 126 } 127 } 128 129 type OASOperation struct { 130 Summary string `json:"summary,omitempty"` 131 Description string `json:"description,omitempty"` 132 OperationID string `json:"operationId,omitempty"` 133 Tags []string `json:"tags,omitempty"` 134 Parameters []OASParameter `json:"parameters,omitempty"` 135 RequestBody *OASRequestBody `json:"requestBody,omitempty"` 136 Responses map[int]*OASResponse `json:"responses"` 137 Deprecated bool `json:"deprecated,omitempty"` 138 } 139 140 type OASParameter struct { 141 Name string `json:"name"` 142 Description string `json:"description,omitempty"` 143 In string `json:"in"` 144 Schema *OASSchema `json:"schema,omitempty"` 145 Required bool `json:"required,omitempty"` 146 Deprecated bool `json:"deprecated,omitempty"` 147 } 148 149 type OASRequestBody struct { 150 Description string `json:"description,omitempty"` 151 Required bool `json:"required,omitempty"` 152 Content OASContent `json:"content,omitempty"` 153 } 154 155 type OASContent map[string]*OASMediaTypeObject 156 157 type OASMediaTypeObject struct { 158 Schema *OASSchema `json:"schema,omitempty"` 159 } 160 161 type OASSchema struct { 162 Ref string `json:"$ref,omitempty"` 163 Type string `json:"type,omitempty"` 164 Description string `json:"description,omitempty"` 165 Properties map[string]*OASSchema `json:"properties,omitempty"` 166 167 AdditionalProperties interface{} `json:"additionalProperties,omitempty"` 168 169 // Required is a list of keys in Properties that are required to be present. This is a different 170 // approach than OASParameter (unfortunately), but is how JSONSchema handles 'required'. 171 Required []string `json:"required,omitempty"` 172 173 Items *OASSchema `json:"items,omitempty"` 174 Format string `json:"format,omitempty"` 175 Pattern string `json:"pattern,omitempty"` 176 Enum []interface{} `json:"enum,omitempty"` 177 Default interface{} `json:"default,omitempty"` 178 Example interface{} `json:"example,omitempty"` 179 Deprecated bool `json:"deprecated,omitempty"` 180 // DisplayName string `json:"x-vault-displayName,omitempty" mapstructure:"x-vault-displayName,omitempty"` 181 DisplayValue interface{} `json:"x-vault-displayValue,omitempty" mapstructure:"x-vault-displayValue,omitempty"` 182 DisplaySensitive bool `json:"x-vault-displaySensitive,omitempty" mapstructure:"x-vault-displaySensitive,omitempty"` 183 DisplayGroup string `json:"x-vault-displayGroup,omitempty" mapstructure:"x-vault-displayGroup,omitempty"` 184 DisplayAttrs *DisplayAttributes `json:"x-vault-displayAttrs,omitempty" mapstructure:"x-vault-displayAttrs,omitempty"` 185 } 186 187 type OASResponse struct { 188 Description string `json:"description"` 189 Content OASContent `json:"content,omitempty"` 190 } 191 192 var OASStdRespOK = &OASResponse{ 193 Description: "OK", 194 } 195 196 var OASStdRespNoContent = &OASResponse{ 197 Description: "empty body", 198 } 199 200 var OASStdRespListOK = &OASResponse{ 201 Description: "OK", 202 Content: OASContent{ 203 "application/json": &OASMediaTypeObject{ 204 Schema: &OASSchema{ 205 Ref: "#/components/schemas/StandardListResponse", 206 }, 207 }, 208 }, 209 } 210 211 var OASStdSchemaStandardListResponse = &OASSchema{ 212 Type: "object", 213 Properties: map[string]*OASSchema{ 214 "keys": { 215 Type: "array", 216 Items: &OASSchema{ 217 Type: "string", 218 }, 219 }, 220 }, 221 } 222 223 // Regex for handling fields in paths, and string cleanup. 224 // Predefined here to avoid substantial recompilation. 225 226 var ( 227 nonWordRe = regexp.MustCompile(`[^\w]+`) // Match a sequence of non-word characters 228 pathFieldsRe = regexp.MustCompile(`{(\w+)}`) // Capture OpenAPI-style named parameters, e.g. "lookup/{urltoken}", 229 wsRe = regexp.MustCompile(`\s+`) // Match whitespace, to be compressed during cleaning 230 ) 231 232 // documentPaths parses all paths in a framework.Backend into OpenAPI paths. 233 func documentPaths(backend *Backend, requestResponsePrefix string, doc *OASDocument) error { 234 for _, p := range backend.Paths { 235 if err := documentPath(p, backend, requestResponsePrefix, doc); err != nil { 236 return err 237 } 238 } 239 240 return nil 241 } 242 243 // documentPath parses a framework.Path into one or more OpenAPI paths. 244 func documentPath(p *Path, backend *Backend, requestResponsePrefix string, doc *OASDocument) error { 245 var sudoPaths []string 246 var unauthPaths []string 247 248 if backend.PathsSpecial != nil { 249 sudoPaths = backend.PathsSpecial.Root 250 unauthPaths = backend.PathsSpecial.Unauthenticated 251 } 252 253 // Convert optional parameters into distinct patterns to be processed independently. 254 forceUnpublished := false 255 paths, captures, err := expandPattern(p.Pattern) 256 if err != nil { 257 if errors.Is(err, errUnsupportableRegexpOperationForOpenAPI) { 258 // Pattern cannot be transformed into sensible OpenAPI paths. In this case, we override the later 259 // processing to use the regexp, as is, as the path, and behave as if Unpublished was set on every 260 // operation (meaning the operations will not be represented in the OpenAPI document). 261 // 262 // This allows a human reading the OpenAPI document to notice that, yes, a path handler does exist, 263 // even though it was not able to contribute actual OpenAPI operations. 264 forceUnpublished = true 265 paths = []string{p.Pattern} 266 } else { 267 return err 268 } 269 } 270 271 for pathIndex, path := range paths { 272 // Construct a top level PathItem which will be populated as the path is processed. 273 pi := OASPathItem{ 274 Description: cleanString(p.HelpSynopsis), 275 } 276 277 pi.Sudo = specialPathMatch(path, sudoPaths) 278 pi.Unauthenticated = specialPathMatch(path, unauthPaths) 279 pi.DisplayAttrs = withoutOperationHints(p.DisplayAttrs) 280 281 // If the newer style Operations map isn't defined, create one from the legacy fields. 282 operations := p.Operations 283 if operations == nil { 284 operations = make(map[logical.Operation]OperationHandler) 285 286 for opType, cb := range p.Callbacks { 287 operations[opType] = &PathOperation{ 288 Callback: cb, 289 Summary: p.HelpSynopsis, 290 } 291 } 292 } 293 294 // Process path and header parameters, which are common to all operations. 295 // Body fields will be added to individual operations. 296 pathFields, queryFields, bodyFields := splitFields(p.Fields, path, captures) 297 298 for name, field := range pathFields { 299 t := convertType(field.Type) 300 p := OASParameter{ 301 Name: name, 302 Description: cleanString(field.Description), 303 In: "path", 304 Schema: &OASSchema{ 305 Type: t.baseType, 306 Pattern: t.pattern, 307 Enum: field.AllowedValues, 308 Default: field.Default, 309 DisplayAttrs: withoutOperationHints(field.DisplayAttrs), 310 }, 311 Required: true, 312 Deprecated: field.Deprecated, 313 } 314 pi.Parameters = append(pi.Parameters, p) 315 } 316 317 // Sort parameters for a stable output 318 sort.Slice(pi.Parameters, func(i, j int) bool { 319 return pi.Parameters[i].Name < pi.Parameters[j].Name 320 }) 321 322 // Process each supported operation by building up an Operation object 323 // with descriptions, properties and examples from the framework.Path data. 324 var listOperation *OASOperation 325 for opType, opHandler := range operations { 326 props := opHandler.Properties() 327 if props.Unpublished || forceUnpublished { 328 continue 329 } 330 331 if opType == logical.CreateOperation { 332 pi.CreateSupported = true 333 334 // If both Create and Update are defined, only process Update. 335 if operations[logical.UpdateOperation] != nil { 336 continue 337 } 338 } 339 340 op := NewOASOperation() 341 342 operationID := constructOperationID( 343 path, 344 pathIndex, 345 p.DisplayAttrs, 346 opType, 347 props.DisplayAttrs, 348 requestResponsePrefix, 349 ) 350 351 op.Summary = props.Summary 352 op.Description = props.Description 353 op.Deprecated = props.Deprecated 354 op.OperationID = operationID 355 356 switch opType { 357 // For the operation types which map to POST/PUT methods, and so allow for request body parameters, 358 // prepare the request body definition 359 case logical.CreateOperation: 360 fallthrough 361 case logical.UpdateOperation: 362 s := &OASSchema{ 363 Type: "object", 364 Properties: make(map[string]*OASSchema), 365 Required: make([]string, 0), 366 } 367 368 for name, field := range bodyFields { 369 // Removing this field from the spec as it is deprecated in favor of using "sha256" 370 // The duplicate sha_256 and sha256 in these paths cause issues with codegen 371 if name == "sha_256" && strings.Contains(path, "plugins/catalog/") { 372 continue 373 } 374 375 addFieldToOASSchema(s, name, field) 376 } 377 378 // Contrary to what one might guess, fields marked with "Query: true" are only query fields when the 379 // request method is one which does not allow for a request body - they are still body fields when 380 // dealing with a POST/PUT request. 381 for name, field := range queryFields { 382 addFieldToOASSchema(s, name, field) 383 } 384 385 // Make the ordering deterministic, so that the generated OpenAPI spec document, observed over several 386 // versions, doesn't contain spurious non-semantic changes. 387 sort.Strings(s.Required) 388 389 // If examples were given, use the first one as the sample 390 // of this schema. 391 if len(props.Examples) > 0 { 392 s.Example = props.Examples[0].Data 393 } 394 395 // TakesArbitraryInput is a case like writing to: 396 // - sys/wrapping/wrap 397 // - kv-v1/{path} 398 // - cubbyhole/{path} 399 // where the entire request body is an arbitrary JSON object used directly as input. 400 if p.TakesArbitraryInput { 401 // Whilst the default value of additionalProperties is true according to the JSON Schema standard, 402 // making this explicit helps communicate this to humans, and also tools such as 403 // https://openapi-generator.tech/ which treat it as defaulting to false. 404 s.AdditionalProperties = true 405 } 406 407 // Set the final request body. Only JSON request data is supported. 408 if len(s.Properties) > 0 { 409 requestName := hyphenatedToTitleCase(operationID) + "Request" 410 doc.Components.Schemas[requestName] = s 411 op.RequestBody = &OASRequestBody{ 412 Required: true, 413 Content: OASContent{ 414 "application/json": &OASMediaTypeObject{ 415 Schema: &OASSchema{Ref: fmt.Sprintf("#/components/schemas/%s", requestName)}, 416 }, 417 }, 418 } 419 } else if p.TakesArbitraryInput { 420 // When there are no properties, the schema is trivial enough that it makes more sense to write it 421 // inline, rather than as a named component. 422 op.RequestBody = &OASRequestBody{ 423 Required: true, 424 Content: OASContent{ 425 "application/json": &OASMediaTypeObject{ 426 Schema: s, 427 }, 428 }, 429 } 430 } 431 432 // For the operation types which map to HTTP methods without a request body, populate query parameters 433 case logical.ListOperation: 434 // LIST is represented as GET with a `list` query parameter. Code later on in this function will assign 435 // list operations to a path with an extra trailing slash, ensuring they do not collide with read 436 // operations. 437 op.Parameters = append(op.Parameters, OASParameter{ 438 Name: "list", 439 Description: "Must be set to `true`", 440 Required: true, 441 In: "query", 442 Schema: &OASSchema{Type: "string", Enum: []interface{}{"true"}}, 443 }) 444 fallthrough 445 case logical.DeleteOperation: 446 fallthrough 447 case logical.ReadOperation: 448 for name, field := range queryFields { 449 t := convertType(field.Type) 450 p := OASParameter{ 451 Name: name, 452 Description: cleanString(field.Description), 453 In: "query", 454 Schema: &OASSchema{ 455 Type: t.baseType, 456 Pattern: t.pattern, 457 Enum: field.AllowedValues, 458 Default: field.Default, 459 DisplayAttrs: withoutOperationHints(field.DisplayAttrs), 460 }, 461 Deprecated: field.Deprecated, 462 } 463 op.Parameters = append(op.Parameters, p) 464 } 465 466 // Sort parameters for a stable output 467 sort.Slice(op.Parameters, func(i, j int) bool { 468 return op.Parameters[i].Name < op.Parameters[j].Name 469 }) 470 } 471 472 // Add tags based on backend type 473 var tags []string 474 switch backend.BackendType { 475 case logical.TypeLogical: 476 tags = []string{"secrets"} 477 case logical.TypeCredential: 478 tags = []string{"auth"} 479 } 480 481 op.Tags = append(op.Tags, tags...) 482 483 // Set default responses. 484 if len(props.Responses) == 0 { 485 if opType == logical.DeleteOperation { 486 op.Responses[204] = OASStdRespNoContent 487 } else if opType == logical.ListOperation { 488 op.Responses[200] = OASStdRespListOK 489 doc.Components.Schemas["StandardListResponse"] = OASStdSchemaStandardListResponse 490 } else { 491 op.Responses[200] = OASStdRespOK 492 } 493 } 494 495 // Add any defined response details. 496 for code, responses := range props.Responses { 497 var description string 498 content := make(OASContent) 499 500 for i, resp := range responses { 501 if i == 0 { 502 description = resp.Description 503 } 504 if resp.Example != nil { 505 mediaType := resp.MediaType 506 if mediaType == "" { 507 mediaType = "application/json" 508 } 509 510 // create a version of the response that will not emit null items 511 cr := cleanResponse(resp.Example) 512 513 // Only one example per media type is allowed, so first one wins 514 if _, ok := content[mediaType]; !ok { 515 content[mediaType] = &OASMediaTypeObject{ 516 Schema: &OASSchema{ 517 Example: cr, 518 }, 519 } 520 } 521 } 522 523 responseSchema := &OASSchema{ 524 Type: "object", 525 Properties: make(map[string]*OASSchema), 526 } 527 528 for name, field := range resp.Fields { 529 openapiField := convertType(field.Type) 530 p := OASSchema{ 531 Type: openapiField.baseType, 532 Description: cleanString(field.Description), 533 Format: openapiField.format, 534 Pattern: openapiField.pattern, 535 Enum: field.AllowedValues, 536 Default: field.Default, 537 Deprecated: field.Deprecated, 538 DisplayAttrs: withoutOperationHints(field.DisplayAttrs), 539 } 540 if openapiField.baseType == "array" { 541 p.Items = &OASSchema{ 542 Type: openapiField.items, 543 } 544 } 545 responseSchema.Properties[name] = &p 546 } 547 548 if len(resp.Fields) != 0 { 549 responseName := hyphenatedToTitleCase(operationID) + "Response" 550 doc.Components.Schemas[responseName] = responseSchema 551 content = OASContent{ 552 "application/json": &OASMediaTypeObject{ 553 Schema: &OASSchema{Ref: fmt.Sprintf("#/components/schemas/%s", responseName)}, 554 }, 555 } 556 } 557 } 558 559 op.Responses[code] = &OASResponse{ 560 Description: description, 561 Content: content, 562 } 563 } 564 565 switch opType { 566 case logical.CreateOperation, logical.UpdateOperation: 567 pi.Post = op 568 case logical.ReadOperation: 569 pi.Get = op 570 case logical.DeleteOperation: 571 pi.Delete = op 572 case logical.ListOperation: 573 listOperation = op 574 } 575 } 576 577 // The conventions enforced by the Vault HTTP routing code make it impossible to match a path with a trailing 578 // slash to anything other than a ListOperation. Catch mistakes in path definition, to enforce that if both of 579 // the two following blocks of code (non-list, and list) write an OpenAPI path to the output document, then the 580 // first one will definitely not have a trailing slash. 581 originalPathHasTrailingSlash := strings.HasSuffix(path, "/") 582 if originalPathHasTrailingSlash && (pi.Get != nil || pi.Post != nil || pi.Delete != nil) { 583 backend.Logger().Warn( 584 "OpenAPI spec generation: discarding impossible-to-invoke non-list operations from path with "+ 585 "required trailing slash; this is a bug in the backend code", "path", path) 586 pi.Get = nil 587 pi.Post = nil 588 pi.Delete = nil 589 } 590 591 // Write the regular, non-list, OpenAPI path to the OpenAPI document, UNLESS we generated a ListOperation, and 592 // NO OTHER operation types. In that fairly common case (there are lots of list-only endpoints), we avoid 593 // writing a redundant OpenAPI path for (e.g.) "auth/token/accessors" with no operations, only to then write 594 // one for "auth/token/accessors/" immediately below. 595 // 596 // On the other hand, we do still write the OpenAPI path here if we generated ZERO operation types - this serves 597 // to provide documentation to a human that an endpoint exists, even if it has no invokable OpenAPI operations. 598 // Examples of this include kv-v2's ".*" endpoint (regex cannot be translated to OpenAPI parameters), and the 599 // auth/oci/login endpoint (implements ResolveRoleOperation only, only callable from inside Vault). 600 if listOperation == nil || pi.Get != nil || pi.Post != nil || pi.Delete != nil { 601 openAPIPath := "/" + path 602 if doc.Paths[openAPIPath] != nil { 603 backend.Logger().Warn( 604 "OpenAPI spec generation: multiple framework.Path instances generated the same path; "+ 605 "last processed wins", "path", openAPIPath) 606 } 607 doc.Paths[openAPIPath] = &pi 608 } 609 610 // If there is a ListOperation, write it to a separate OpenAPI path in the document. 611 if listOperation != nil { 612 // Append a slash here to disambiguate from the path written immediately above. 613 // However, if the path already contains a trailing slash, we want to avoid doubling it, and it is 614 // guaranteed (through the interaction of logic in the last two blocks) that the block immediately above 615 // will NOT have written a path to the OpenAPI document. 616 if !originalPathHasTrailingSlash { 617 path += "/" 618 } 619 620 listPathItem := OASPathItem{ 621 Description: pi.Description, 622 Parameters: pi.Parameters, 623 DisplayAttrs: pi.DisplayAttrs, 624 625 // Since the path may now have an extra slash on the end, we need to recalculate the special path 626 // matches, as the sudo or unauthenticated status may be changed as a result! 627 Sudo: specialPathMatch(path, sudoPaths), 628 Unauthenticated: specialPathMatch(path, unauthPaths), 629 630 Get: listOperation, 631 } 632 633 openAPIPath := "/" + path 634 if doc.Paths[openAPIPath] != nil { 635 backend.Logger().Warn( 636 "OpenAPI spec generation: multiple framework.Path instances generated the same path; "+ 637 "last processed wins", "path", openAPIPath) 638 } 639 doc.Paths[openAPIPath] = &listPathItem 640 } 641 } 642 643 return nil 644 } 645 646 func addFieldToOASSchema(s *OASSchema, name string, field *FieldSchema) { 647 openapiField := convertType(field.Type) 648 if field.Required { 649 s.Required = append(s.Required, name) 650 } 651 652 p := OASSchema{ 653 Type: openapiField.baseType, 654 Description: cleanString(field.Description), 655 Format: openapiField.format, 656 Pattern: openapiField.pattern, 657 Enum: field.AllowedValues, 658 Default: field.Default, 659 Deprecated: field.Deprecated, 660 DisplayAttrs: withoutOperationHints(field.DisplayAttrs), 661 } 662 if openapiField.baseType == "array" { 663 p.Items = &OASSchema{ 664 Type: openapiField.items, 665 } 666 } 667 668 s.Properties[name] = &p 669 } 670 671 // specialPathMatch checks whether the given path matches one of the special 672 // paths, taking into account * and + wildcards (e.g. foo/+/bar/*) 673 func specialPathMatch(path string, specialPaths []string) bool { 674 // pathMatchesByParts determines if the path matches the special path's 675 // pattern, accounting for the '+' and '*' wildcards 676 pathMatchesByParts := func(pathParts []string, specialPathParts []string) bool { 677 if len(pathParts) < len(specialPathParts) { 678 return false 679 } 680 for i := 0; i < len(specialPathParts); i++ { 681 var ( 682 part = pathParts[i] 683 pattern = specialPathParts[i] 684 ) 685 if pattern == "+" { 686 continue 687 } 688 if pattern == "*" { 689 return true 690 } 691 if strings.HasSuffix(pattern, "*") && strings.HasPrefix(part, pattern[0:len(pattern)-1]) { 692 return true 693 } 694 if pattern != part { 695 return false 696 } 697 } 698 return len(pathParts) == len(specialPathParts) 699 } 700 701 pathParts := strings.Split(path, "/") 702 703 for _, sp := range specialPaths { 704 // exact match 705 if sp == path { 706 return true 707 } 708 709 // match * 710 if strings.HasSuffix(sp, "*") && strings.HasPrefix(path, sp[0:len(sp)-1]) { 711 return true 712 } 713 714 // match + 715 if strings.Contains(sp, "+") && pathMatchesByParts(pathParts, strings.Split(sp, "/")) { 716 return true 717 } 718 } 719 720 return false 721 } 722 723 // constructOperationID joins the given inputs into a hyphen-separated 724 // lower-case operation id, which is also used as a prefix for request and 725 // response names. 726 // 727 // The OperationPrefix / -Verb / -Suffix found in display attributes will be 728 // used, if provided. Otherwise, the function falls back to using the path and 729 // the operation. 730 // 731 // Examples of generated operation identifiers: 732 // - kvv2-write 733 // - kvv2-read 734 // - google-cloud-login 735 // - google-cloud-write-role 736 func constructOperationID( 737 path string, 738 pathIndex int, 739 pathAttributes *DisplayAttributes, 740 operation logical.Operation, 741 operationAttributes *DisplayAttributes, 742 defaultPrefix string, 743 ) string { 744 var ( 745 prefix string 746 verb string 747 suffix string 748 ) 749 750 if operationAttributes != nil { 751 prefix = operationAttributes.OperationPrefix 752 verb = operationAttributes.OperationVerb 753 suffix = operationAttributes.OperationSuffix 754 } 755 756 if pathAttributes != nil { 757 if prefix == "" { 758 prefix = pathAttributes.OperationPrefix 759 } 760 if verb == "" { 761 verb = pathAttributes.OperationVerb 762 } 763 if suffix == "" { 764 suffix = pathAttributes.OperationSuffix 765 } 766 } 767 768 // A single suffix string can contain multiple pipe-delimited strings. To 769 // determine the actual suffix, we attempt to match it by the index of the 770 // paths returned from `expandPattern(...)`. For example: 771 // 772 // pki/ 773 // Pattern: "keys/generate/(internal|exported|kms)", 774 // DisplayAttrs: { 775 // ... 776 // OperationSuffix: "internal-key|exported-key|kms-key", 777 // }, 778 // 779 // will expand into three paths and corresponding suffixes: 780 // 781 // path 0: "keys/generate/internal" suffix: internal-key 782 // path 1: "keys/generate/exported" suffix: exported-key 783 // path 2: "keys/generate/kms" suffix: kms-key 784 // 785 pathIndexOutOfRange := false 786 787 if suffixes := strings.Split(suffix, "|"); len(suffixes) > 1 || pathIndex > 0 { 788 // if the index is out of bounds, fall back to the old logic 789 if pathIndex >= len(suffixes) { 790 suffix = "" 791 pathIndexOutOfRange = true 792 } else { 793 suffix = suffixes[pathIndex] 794 } 795 } 796 797 // a helper that hyphenates & lower-cases the slice except the empty elements 798 toLowerHyphenate := func(parts []string) string { 799 filtered := make([]string, 0, len(parts)) 800 for _, e := range parts { 801 if e != "" { 802 filtered = append(filtered, e) 803 } 804 } 805 return strings.ToLower(strings.Join(filtered, "-")) 806 } 807 808 // fall back to using the path + operation to construct the operation id 809 var ( 810 needPrefix = prefix == "" && verb == "" 811 needVerb = verb == "" 812 needSuffix = suffix == "" && (verb == "" || pathIndexOutOfRange) 813 ) 814 815 if needPrefix { 816 prefix = defaultPrefix 817 } 818 819 if needVerb { 820 if operation == logical.UpdateOperation { 821 verb = "write" 822 } else { 823 verb = string(operation) 824 } 825 } 826 827 if needSuffix { 828 suffix = toLowerHyphenate(nonWordRe.Split(path, -1)) 829 } 830 831 return toLowerHyphenate([]string{prefix, verb, suffix}) 832 } 833 834 // expandPattern expands a regex pattern by generating permutations of any optional parameters 835 // and changing named parameters into their {openapi} equivalents. It also returns the names of all capturing groups 836 // observed in the pattern. 837 func expandPattern(pattern string) (paths []string, captures map[string]struct{}, err error) { 838 // Happily, the Go regexp library exposes its underlying "parse to AST" functionality, so we can rely on that to do 839 // the hard work of interpreting the regexp syntax. 840 rx, err := syntax.Parse(pattern, syntax.Perl) 841 if err != nil { 842 // This should be impossible to reach, since regexps have previously been compiled with MustCompile in 843 // Backend.init. 844 panic(err) 845 } 846 847 paths, captures, err = collectPathsFromRegexpAST(rx) 848 if err != nil { 849 return nil, nil, err 850 } 851 852 return paths, captures, nil 853 } 854 855 type pathCollector struct { 856 strings.Builder 857 conditionalSlashAppendedAtLength int 858 } 859 860 // collectPathsFromRegexpAST performs a depth-first recursive walk through a regexp AST, collecting an OpenAPI-style 861 // path as it goes. 862 // 863 // Each time it encounters alternation (a|b) or an optional part (a?), it forks its processing to produce additional 864 // results, to account for each possibility. Note: This does mean that an input pattern with lots of these regexp 865 // features can produce a lot of different OpenAPI endpoints. At the time of writing, the most complex known example is 866 // 867 // "issuer/" + framework.GenericNameRegex(issuerRefParam) + "/crl(/pem|/der|/delta(/pem|/der)?)?" 868 // 869 // in the PKI secrets engine which expands to 6 separate paths. 870 // 871 // Each named capture group - i.e. (?P<name>something here) - is replaced with an OpenAPI parameter - i.e. {name} - and 872 // the subtree of regexp AST inside the parameter is completely skipped. 873 func collectPathsFromRegexpAST(rx *syntax.Regexp) (paths []string, captures map[string]struct{}, err error) { 874 captures = make(map[string]struct{}) 875 pathCollectors, err := collectPathsFromRegexpASTInternal(rx, []*pathCollector{{}}, captures) 876 if err != nil { 877 return nil, nil, err 878 } 879 paths = make([]string, 0, len(pathCollectors)) 880 for _, collector := range pathCollectors { 881 if collector.conditionalSlashAppendedAtLength != collector.Len() { 882 paths = append(paths, collector.String()) 883 } 884 } 885 return paths, captures, nil 886 } 887 888 var errUnsupportableRegexpOperationForOpenAPI = errors.New("path regexp uses an operation that cannot be translated to an OpenAPI pattern") 889 890 func collectPathsFromRegexpASTInternal( 891 rx *syntax.Regexp, 892 appendingTo []*pathCollector, 893 captures map[string]struct{}, 894 ) ([]*pathCollector, error) { 895 var err error 896 897 // Depending on the type of this regexp AST node (its Op, i.e. operation), figure out whether it contributes any 898 // characters to the URL path, and whether we need to recurse through child AST nodes. 899 // 900 // Each element of the appendingTo slice tracks a separate path, defined by the alternatives chosen when traversing 901 // the | and ? conditional regexp features, and new elements are added as each of these features are traversed. 902 // 903 // To share this slice across multiple recursive calls of this function, it is passed down as a parameter to each 904 // recursive call, potentially modified throughout this switch block, and passed back up as a return value at the 905 // end of this function - the parent call uses the return value to update its own local variable. 906 switch rx.Op { 907 908 // These AST operations are leaf nodes (no children), that match zero characters, so require no processing at all 909 case syntax.OpEmptyMatch: // e.g. (?:) 910 case syntax.OpBeginLine: // i.e. ^ when (?m) 911 case syntax.OpEndLine: // i.e. $ when (?m) 912 case syntax.OpBeginText: // i.e. \A, or ^ when (?-m) 913 case syntax.OpEndText: // i.e. \z, or $ when (?-m) 914 case syntax.OpWordBoundary: // i.e. \b 915 case syntax.OpNoWordBoundary: // i.e. \B 916 917 // OpConcat simply represents multiple parts of the pattern appearing one after the other, so just recurse through 918 // those pieces. 919 case syntax.OpConcat: 920 for _, child := range rx.Sub { 921 appendingTo, err = collectPathsFromRegexpASTInternal(child, appendingTo, captures) 922 if err != nil { 923 return nil, err 924 } 925 } 926 927 // OpLiteral is a literal string in the pattern - append it to the paths we are building. 928 case syntax.OpLiteral: 929 for _, collector := range appendingTo { 930 collector.WriteString(string(rx.Rune)) 931 } 932 933 // OpAlternate, i.e. a|b, means we clone all of the pathCollector instances we are currently accumulating paths 934 // into, and independently recurse through each alternate option. 935 case syntax.OpAlternate: // i.e | 936 var totalAppendingTo []*pathCollector 937 lastIndex := len(rx.Sub) - 1 938 for index, child := range rx.Sub { 939 var childAppendingTo []*pathCollector 940 if index == lastIndex { 941 // Optimization: last time through this loop, we can simply re-use the existing set of pathCollector 942 // instances, as we no longer need to preserve them unmodified to make further copies of. 943 childAppendingTo = appendingTo 944 } else { 945 for _, collector := range appendingTo { 946 newCollector := new(pathCollector) 947 newCollector.WriteString(collector.String()) 948 newCollector.conditionalSlashAppendedAtLength = collector.conditionalSlashAppendedAtLength 949 childAppendingTo = append(childAppendingTo, newCollector) 950 } 951 } 952 childAppendingTo, err = collectPathsFromRegexpASTInternal(child, childAppendingTo, captures) 953 if err != nil { 954 return nil, err 955 } 956 totalAppendingTo = append(totalAppendingTo, childAppendingTo...) 957 } 958 appendingTo = totalAppendingTo 959 960 // OpQuest, i.e. a?, is much like an alternation between exactly two options, one of which is the empty string. 961 case syntax.OpQuest: 962 child := rx.Sub[0] 963 var childAppendingTo []*pathCollector 964 for _, collector := range appendingTo { 965 newCollector := new(pathCollector) 966 newCollector.WriteString(collector.String()) 967 newCollector.conditionalSlashAppendedAtLength = collector.conditionalSlashAppendedAtLength 968 childAppendingTo = append(childAppendingTo, newCollector) 969 } 970 childAppendingTo, err = collectPathsFromRegexpASTInternal(child, childAppendingTo, captures) 971 if err != nil { 972 return nil, err 973 } 974 appendingTo = append(appendingTo, childAppendingTo...) 975 976 // Many Vault path patterns end with `/?` to accept paths that end with or without a slash. Our current 977 // convention for generating the OpenAPI is to strip away these slashes. To do that, this very special case 978 // detects when we just appended a single conditional slash, and records the length of the path at this point, 979 // so we can later discard this path variant, if nothing else is appended to it later. 980 if child.Op == syntax.OpLiteral && string(child.Rune) == "/" { 981 for _, collector := range childAppendingTo { 982 collector.conditionalSlashAppendedAtLength = collector.Len() 983 } 984 } 985 986 // OpCapture, i.e. ( ) or (?P<name> ), a capturing group 987 case syntax.OpCapture: 988 if rx.Name == "" { 989 // In Vault, an unnamed capturing group is not actually used for capturing. 990 // We treat it exactly the same as OpConcat. 991 for _, child := range rx.Sub { 992 appendingTo, err = collectPathsFromRegexpASTInternal(child, appendingTo, captures) 993 if err != nil { 994 return nil, err 995 } 996 } 997 } else { 998 // A named capturing group is replaced with the OpenAPI parameter syntax, and the regexp inside the group 999 // is NOT added to the OpenAPI path. 1000 for _, builder := range appendingTo { 1001 builder.WriteRune('{') 1002 builder.WriteString(rx.Name) 1003 builder.WriteRune('}') 1004 } 1005 captures[rx.Name] = struct{}{} 1006 } 1007 1008 // Any other kind of operation is a problem, and will trigger an error, resulting in the pattern being left out of 1009 // the OpenAPI entirely - that's better than generating a path which is incorrect. 1010 // 1011 // The Op types we expect to hit the default condition are: 1012 // 1013 // OpCharClass - i.e. [something] 1014 // OpAnyCharNotNL - i.e. . 1015 // OpAnyChar - i.e. (?s:.) 1016 // OpStar - i.e. * 1017 // OpPlus - i.e. + 1018 // OpRepeat - i.e. {N}, {N,M}, etc. 1019 // 1020 // In any of these conditions, there is no sensible translation of the path to OpenAPI syntax. (Note, this only 1021 // applies to these appearing outside of a named capture group, otherwise they are handled in the previous case.) 1022 // 1023 // At the time of writing, the only pattern in the builtin Vault plugins that hits this codepath is the ".*" 1024 // pattern in the KVv2 secrets engine, which is not a valid path, but rather, is a catch-all used to implement 1025 // custom error handling behaviour to guide users who attempt to treat a KVv2 as a KVv1. It is already marked as 1026 // Unpublished, so is withheld from the OpenAPI anyway. 1027 // 1028 // For completeness, one other Op type exists, OpNoMatch, which is never generated by syntax.Parse - only by 1029 // subsequent Simplify in preparation to Compile, which is not used here. 1030 default: 1031 return nil, errUnsupportableRegexpOperationForOpenAPI 1032 } 1033 1034 return appendingTo, nil 1035 } 1036 1037 // schemaType is a subset of the JSON Schema elements used as a target 1038 // for conversions from Vault's standard FieldTypes. 1039 type schemaType struct { 1040 baseType string 1041 items string 1042 format string 1043 pattern string 1044 } 1045 1046 // convertType translates a FieldType into an OpenAPI type. 1047 // In the case of arrays, a subtype is returned as well. 1048 func convertType(t FieldType) schemaType { 1049 ret := schemaType{} 1050 1051 switch t { 1052 case TypeString, TypeHeader: 1053 ret.baseType = "string" 1054 case TypeNameString: 1055 ret.baseType = "string" 1056 ret.pattern = `\w([\w-.]*\w)?` 1057 case TypeLowerCaseString: 1058 ret.baseType = "string" 1059 ret.format = "lowercase" 1060 case TypeInt: 1061 ret.baseType = "integer" 1062 case TypeInt64: 1063 ret.baseType = "integer" 1064 ret.format = "int64" 1065 case TypeDurationSecond, TypeSignedDurationSecond: 1066 ret.baseType = "string" 1067 ret.format = "duration" 1068 case TypeBool: 1069 ret.baseType = "boolean" 1070 case TypeMap: 1071 ret.baseType = "object" 1072 ret.format = "map" 1073 case TypeKVPairs: 1074 ret.baseType = "object" 1075 ret.format = "kvpairs" 1076 case TypeSlice: 1077 ret.baseType = "array" 1078 ret.items = "object" 1079 case TypeStringSlice, TypeCommaStringSlice: 1080 ret.baseType = "array" 1081 ret.items = "string" 1082 case TypeCommaIntSlice: 1083 ret.baseType = "array" 1084 ret.items = "integer" 1085 case TypeTime: 1086 ret.baseType = "string" 1087 ret.format = "date-time" 1088 case TypeFloat: 1089 ret.baseType = "number" 1090 ret.format = "float" 1091 default: 1092 log.L().Warn("error parsing field type", "type", t) 1093 ret.format = "unknown" 1094 } 1095 1096 return ret 1097 } 1098 1099 // cleanString prepares s for inclusion in the output 1100 func cleanString(s string) string { 1101 // clean leading/trailing whitespace, and replace whitespace runs into a single space 1102 s = strings.TrimSpace(s) 1103 s = wsRe.ReplaceAllString(s, " ") 1104 return s 1105 } 1106 1107 // splitFields partitions fields into path, query and body groups. It uses information on capturing groups previously 1108 // collected by expandPattern, which is necessary to correctly match the treatment in (*Backend).HandleRequest: 1109 // a field counts as a path field if it appears in any capture in the regex, and if that capture was inside an 1110 // alternation or optional part of the regex which does not survive in the OpenAPI path pattern currently being 1111 // processed, that field should NOT be rendered to the OpenAPI spec AT ALL. 1112 func splitFields( 1113 allFields map[string]*FieldSchema, 1114 openAPIPathPattern string, 1115 captures map[string]struct{}, 1116 ) (pathFields, queryFields, bodyFields map[string]*FieldSchema) { 1117 pathFields = make(map[string]*FieldSchema) 1118 queryFields = make(map[string]*FieldSchema) 1119 bodyFields = make(map[string]*FieldSchema) 1120 1121 for _, match := range pathFieldsRe.FindAllStringSubmatch(openAPIPathPattern, -1) { 1122 name := match[1] 1123 pathFields[name] = allFields[name] 1124 } 1125 1126 for name, field := range allFields { 1127 // Any field which relates to a regex capture was already processed above, if it needed to be. 1128 if _, ok := captures[name]; !ok { 1129 if field.Query { 1130 queryFields[name] = field 1131 } else { 1132 bodyFields[name] = field 1133 } 1134 } 1135 } 1136 1137 return pathFields, queryFields, bodyFields 1138 } 1139 1140 // withoutOperationHints returns a copy of the given DisplayAttributes without 1141 // OperationPrefix / OperationVerb / OperationSuffix since we don't need these 1142 // fields in the final output. 1143 func withoutOperationHints(in *DisplayAttributes) *DisplayAttributes { 1144 if in == nil { 1145 return nil 1146 } 1147 1148 copy := *in 1149 1150 copy.OperationPrefix = "" 1151 copy.OperationVerb = "" 1152 copy.OperationSuffix = "" 1153 1154 // return nil if all fields are empty to avoid empty JSON objects 1155 if copy == (DisplayAttributes{}) { 1156 return nil 1157 } 1158 1159 return © 1160 } 1161 1162 func hyphenatedToTitleCase(in string) string { 1163 var b strings.Builder 1164 1165 title := cases.Title(language.English, cases.NoLower) 1166 1167 for _, word := range strings.Split(in, "-") { 1168 b.WriteString(title.String(word)) 1169 } 1170 1171 return b.String() 1172 } 1173 1174 // cleanedResponse is identical to logical.Response but with nulls 1175 // removed from from JSON encoding 1176 type cleanedResponse struct { 1177 Secret *logical.Secret `json:"secret,omitempty"` 1178 Auth *logical.Auth `json:"auth,omitempty"` 1179 Data map[string]interface{} `json:"data,omitempty"` 1180 Redirect string `json:"redirect,omitempty"` 1181 Warnings []string `json:"warnings,omitempty"` 1182 WrapInfo *wrapping.ResponseWrapInfo `json:"wrap_info,omitempty"` 1183 Headers map[string][]string `json:"headers,omitempty"` 1184 MountType string `json:"mount_type,omitempty"` 1185 } 1186 1187 func cleanResponse(resp *logical.Response) *cleanedResponse { 1188 return &cleanedResponse{ 1189 Secret: resp.Secret, 1190 Auth: resp.Auth, 1191 Data: resp.Data, 1192 Redirect: resp.Redirect, 1193 Warnings: resp.Warnings, 1194 WrapInfo: resp.WrapInfo, 1195 Headers: resp.Headers, 1196 MountType: resp.MountType, 1197 } 1198 } 1199 1200 // CreateOperationIDs generates unique operationIds for all paths/methods. 1201 // The transform will convert path/method into camelcase. e.g.: 1202 // 1203 // /sys/tools/random/{urlbytes} -> postSysToolsRandomUrlbytes 1204 // 1205 // In the unlikely case of a duplicate ids, a numeric suffix is added: 1206 // 1207 // postSysToolsRandomUrlbytes_2 1208 // 1209 // An optional user-provided suffix ("context") may also be appended. 1210 // 1211 // Deprecated: operationID's are now populated using `constructOperationID`. 1212 // This function is here for backwards compatibility with older plugins. 1213 func (d *OASDocument) CreateOperationIDs(context string) { 1214 opIDCount := make(map[string]int) 1215 var paths []string 1216 1217 // traverse paths in a stable order to ensure stable output 1218 for path := range d.Paths { 1219 paths = append(paths, path) 1220 } 1221 sort.Strings(paths) 1222 1223 for _, path := range paths { 1224 pi := d.Paths[path] 1225 for _, method := range []string{"get", "post", "delete"} { 1226 var oasOperation *OASOperation 1227 switch method { 1228 case "get": 1229 oasOperation = pi.Get 1230 case "post": 1231 oasOperation = pi.Post 1232 case "delete": 1233 oasOperation = pi.Delete 1234 } 1235 1236 if oasOperation == nil { 1237 continue 1238 } 1239 1240 if oasOperation.OperationID != "" { 1241 continue 1242 } 1243 1244 // Discard "_mount_path" from any {thing_mount_path} parameters 1245 path = strings.Replace(path, "_mount_path", "", 1) 1246 1247 // Space-split on non-words, title case everything, recombine 1248 opID := nonWordRe.ReplaceAllString(strings.ToLower(path), " ") 1249 opID = strings.Title(opID) 1250 opID = method + strings.ReplaceAll(opID, " ", "") 1251 1252 // deduplicate operationIds. This is a safeguard, since generated IDs should 1253 // already be unique given our current path naming conventions. 1254 opIDCount[opID]++ 1255 if opIDCount[opID] > 1 { 1256 opID = fmt.Sprintf("%s_%d", opID, opIDCount[opID]) 1257 } 1258 1259 if context != "" { 1260 opID += "_" + context 1261 } 1262 1263 oasOperation.OperationID = opID 1264 } 1265 } 1266 }