Skip to content

Commit

Permalink
Global flag that outputs minimum policy HCL required for an operation (
Browse files Browse the repository at this point in the history
…#14899)

* WIP: output policy

* Outputs example policy HCL for given request

* Simplify conditional

* Add PATCH capability

* Use OpenAPI spec and regex patterns to determine if path is sudo

* Add test for isSudoPath

* Add changelog

* Fix broken CLI tests

* Add output-policy to client cloning code

* Smaller fixes from PR comments

* Clone client instead of saving and restoring custom values

* Fix test

* Address comments

* Don't unset output-policy flag on KV requests otherwise the preflight request will fail and not populate LastOutputPolicyError

* Print errors saved in buffer from preflight KV requests

* Unescape characters in request URL

* Rename methods and properties to improve readability

* Put KV-specificness at front of KV-specific error

* Simplify logic by doing more direct returns of strings and errors

* Use precompiled regexes and move OpenAPI call to tests

* Remove commented out code

* Remove legacy MFA paths

* Remove unnecessary use of client

* Move sudo paths map to plugin helper

* Remove unused error return

* Add explanatory comment

* Remove need to pass in address

* Make {name} regex less greedy

* Use method and path instead of info from retryablerequest

* Add test for IsSudoPaths, use more idiomatic naming

* Use precompiled regexes and move OpenAPI call to tests (#15170)

* Use precompiled regexes and move OpenAPI call to tests

* Remove commented out code

* Remove legacy MFA paths

* Remove unnecessary use of client

* Move sudo paths map to plugin helper

* Remove unused error return

* Add explanatory comment

* Remove need to pass in address

* Make {name} regex less greedy

* Use method and path instead of info from retryablerequest

* Add test for IsSudoPaths, use more idiomatic naming

* Make stderr writing more obvious, fix nil pointer deref
  • Loading branch information
digivava authored Apr 27, 2022
1 parent f36d072 commit 7089487
Show file tree
Hide file tree
Showing 15 changed files with 508 additions and 58 deletions.
42 changes: 41 additions & 1 deletion api/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,14 @@ type Config struct {
// with the same client. Cloning a client will not clone this value.
OutputCurlString bool

// OutputPolicy causes the actual request to return an error of type
// *OutputPolicyError. Type asserting the error message will display
// an example of the required policy HCL needed for the operation.
//
// Note: It is not thread-safe to set this and make concurrent requests
// with the same client. Cloning a client will not clone this value.
OutputPolicy bool

// curlCACert, curlCAPath, curlClientCert and curlClientKey are used to keep
// track of the name of the TLS certs and keys when OutputCurlString is set.
// Cloning a client will also not clone those values.
Expand Down Expand Up @@ -779,6 +787,24 @@ func (c *Client) SetOutputCurlString(curl bool) {
c.config.OutputCurlString = curl
}

func (c *Client) OutputPolicy() bool {
c.modifyLock.RLock()
defer c.modifyLock.RUnlock()
c.config.modifyLock.RLock()
defer c.config.modifyLock.RUnlock()

return c.config.OutputPolicy
}

func (c *Client) SetOutputPolicy(isSet bool) {
c.modifyLock.RLock()
defer c.modifyLock.RUnlock()
c.config.modifyLock.Lock()
defer c.config.modifyLock.Unlock()

c.config.OutputPolicy = isSet
}

// CurrentWrappingLookupFunc sets a lookup function that returns desired wrap TTLs
// for a given operation and path.
func (c *Client) CurrentWrappingLookupFunc() WrappingLookupFunc {
Expand Down Expand Up @@ -1172,6 +1198,7 @@ func (c *Client) rawRequestWithContext(ctx context.Context, r *Request) (*Respon
httpClient := c.config.HttpClient
ns := c.headers.Get(consts.NamespaceHeaderName)
outputCurlString := c.config.OutputCurlString
outputPolicy := c.config.OutputPolicy
logger := c.config.Logger
c.config.modifyLock.RUnlock()

Expand Down Expand Up @@ -1225,6 +1252,14 @@ START:
return nil, LastOutputStringError
}

if outputPolicy {
LastOutputPolicyError = &OutputPolicyError{
method: req.Method,
path: strings.TrimPrefix(req.URL.Path, "/v1"),
}
return nil, LastOutputPolicyError
}

req.Request = req.Request.WithContext(ctx)

if backoff == nil {
Expand Down Expand Up @@ -1317,6 +1352,8 @@ func (c *Client) httpRequestWithContext(ctx context.Context, r *Request) (*Respo
limiter := c.config.Limiter
httpClient := c.config.HttpClient
outputCurlString := c.config.OutputCurlString
outputPolicy := c.config.OutputPolicy

// add headers
if c.headers != nil {
for header, vals := range c.headers {
Expand All @@ -1333,10 +1370,13 @@ func (c *Client) httpRequestWithContext(ctx context.Context, r *Request) (*Respo
c.config.modifyLock.RUnlock()
c.modifyLock.RUnlock()

// OutputCurlString logic relies on the request type to be retryable.Request as
// OutputCurlString and OutputPolicy logic rely on the request type to be retryable.Request
if outputCurlString {
return nil, fmt.Errorf("output-curl-string is not implemented for this request")
}
if outputPolicy {
return nil, fmt.Errorf("output-policy is not implemented for this request")
}

req.URL.User = r.URL.User
req.URL.Scheme = r.URL.Scheme
Expand Down
1 change: 1 addition & 0 deletions api/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -490,6 +490,7 @@ func TestClone(t *testing.T) {
parent.SetLimiter(5.0, 10)
parent.SetMaxRetries(5)
parent.SetOutputCurlString(true)
parent.SetOutputPolicy(true)
parent.SetSRVLookup(true)

if tt.headers != nil {
Expand Down
82 changes: 82 additions & 0 deletions api/output_policy.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
package api

import (
"fmt"
"net/http"
"net/url"
"strings"
)

const (
ErrOutputPolicyRequest = "output a policy, please"
)

var LastOutputPolicyError *OutputPolicyError

type OutputPolicyError struct {
method string
path string
finalHCLString string
}

func (d *OutputPolicyError) Error() string {
if d.finalHCLString == "" {
p, err := d.buildSamplePolicy()
if err != nil {
return err.Error()
}
d.finalHCLString = p
}

return ErrOutputPolicyRequest
}

func (d *OutputPolicyError) HCLString() (string, error) {
if d.finalHCLString == "" {
p, err := d.buildSamplePolicy()
if err != nil {
return "", err
}
d.finalHCLString = p
}
return d.finalHCLString, nil
}

// Builds a sample policy document from the request
func (d *OutputPolicyError) buildSamplePolicy() (string, error) {
var capabilities []string
switch d.method {
case http.MethodGet, "":
capabilities = append(capabilities, "read")
case http.MethodPost, http.MethodPut:
capabilities = append(capabilities, "create")
capabilities = append(capabilities, "update")
case http.MethodPatch:
capabilities = append(capabilities, "patch")
case http.MethodDelete:
capabilities = append(capabilities, "delete")
case "LIST":
capabilities = append(capabilities, "list")
}

// sanitize, then trim the Vault address and v1 from the front of the path
path, err := url.PathUnescape(d.path)
if err != nil {
return "", fmt.Errorf("failed to unescape request URL characters: %v", err)
}

// determine whether to add sudo capability
if IsSudoPath(path) {
capabilities = append(capabilities, "sudo")
}

// the OpenAPI response has a / in front of each path,
// but policies need the path without that leading slash
path = strings.TrimLeft(path, "/")

capStr := strings.Join(capabilities, `", "`)
return fmt.Sprintf(
`path "%s" {
capabilities = ["%s"]
}`, path, capStr), nil
}
55 changes: 29 additions & 26 deletions api/output_string.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,74 +19,77 @@ type OutputStringError struct {
TLSSkipVerify bool
ClientCACert, ClientCAPath string
ClientCert, ClientKey string
parsingError error
parsedCurlString string
finalCurlString string
}

func (d *OutputStringError) Error() string {
if d.parsedCurlString == "" {
d.parseRequest()
if d.parsingError != nil {
return d.parsingError.Error()
if d.finalCurlString == "" {
cs, err := d.buildCurlString()
if err != nil {
return err.Error()
}
d.finalCurlString = cs
}

return ErrOutputStringRequest
}

func (d *OutputStringError) parseRequest() {
func (d *OutputStringError) CurlString() (string, error) {
if d.finalCurlString == "" {
cs, err := d.buildCurlString()
if err != nil {
return "", err
}
d.finalCurlString = cs
}
return d.finalCurlString, nil
}

func (d *OutputStringError) buildCurlString() (string, error) {
body, err := d.Request.BodyBytes()
if err != nil {
d.parsingError = err
return
return "", err
}

// Build cURL string
d.parsedCurlString = "curl "
finalCurlString := "curl "
if d.TLSSkipVerify {
d.parsedCurlString += "--insecure "
finalCurlString += "--insecure "
}
if d.Request.Method != http.MethodGet {
d.parsedCurlString = fmt.Sprintf("%s-X %s ", d.parsedCurlString, d.Request.Method)
finalCurlString = fmt.Sprintf("%s-X %s ", finalCurlString, d.Request.Method)
}
if d.ClientCACert != "" {
clientCACert := strings.Replace(d.ClientCACert, "'", "'\"'\"'", -1)
d.parsedCurlString = fmt.Sprintf("%s--cacert '%s' ", d.parsedCurlString, clientCACert)
finalCurlString = fmt.Sprintf("%s--cacert '%s' ", finalCurlString, clientCACert)
}
if d.ClientCAPath != "" {
clientCAPath := strings.Replace(d.ClientCAPath, "'", "'\"'\"'", -1)
d.parsedCurlString = fmt.Sprintf("%s--capath '%s' ", d.parsedCurlString, clientCAPath)
finalCurlString = fmt.Sprintf("%s--capath '%s' ", finalCurlString, clientCAPath)
}
if d.ClientCert != "" {
clientCert := strings.Replace(d.ClientCert, "'", "'\"'\"'", -1)
d.parsedCurlString = fmt.Sprintf("%s--cert '%s' ", d.parsedCurlString, clientCert)
finalCurlString = fmt.Sprintf("%s--cert '%s' ", finalCurlString, clientCert)
}
if d.ClientKey != "" {
clientKey := strings.Replace(d.ClientKey, "'", "'\"'\"'", -1)
d.parsedCurlString = fmt.Sprintf("%s--key '%s' ", d.parsedCurlString, clientKey)
finalCurlString = fmt.Sprintf("%s--key '%s' ", finalCurlString, clientKey)
}
for k, v := range d.Request.Header {
for _, h := range v {
if strings.ToLower(k) == "x-vault-token" {
h = `$(vault print token)`
}
d.parsedCurlString = fmt.Sprintf("%s-H \"%s: %s\" ", d.parsedCurlString, k, h)
finalCurlString = fmt.Sprintf("%s-H \"%s: %s\" ", finalCurlString, k, h)
}
}

if len(body) > 0 {
// We need to escape single quotes since that's what we're using to
// quote the body
escapedBody := strings.Replace(string(body), "'", "'\"'\"'", -1)
d.parsedCurlString = fmt.Sprintf("%s-d '%s' ", d.parsedCurlString, escapedBody)
finalCurlString = fmt.Sprintf("%s-d '%s' ", finalCurlString, escapedBody)
}

d.parsedCurlString = fmt.Sprintf("%s%s", d.parsedCurlString, d.Request.URL.String())
}

func (d *OutputStringError) CurlString() string {
if d.parsedCurlString == "" {
d.parseRequest()
}
return d.parsedCurlString
return fmt.Sprintf("%s%s", finalCurlString, d.Request.URL.String()), nil
}
61 changes: 61 additions & 0 deletions api/plugin_helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"flag"
"net/url"
"os"
"regexp"

squarejwt "gopkg.in/square/go-jose.v2/jwt"

Expand All @@ -23,6 +24,41 @@ var (
// PluginUnwrapTokenEnv is the ENV name used to pass unwrap tokens to the
// plugin.
PluginUnwrapTokenEnv = "VAULT_UNWRAP_TOKEN"

// sudoPaths is a map containing the paths that require a token's policy
// to have the "sudo" capability. The keys are the paths as strings, in
// the same format as they are returned by the OpenAPI spec. The values
// are the regular expressions that can be used to test whether a given
// path matches that path or not (useful specifically for the paths that
// contain templated fields.)
sudoPaths = map[string]*regexp.Regexp{
"/auth/token/accessors/": regexp.MustCompile(`^/auth/token/accessors/$`),
"/pki/root": regexp.MustCompile(`^/pki/root$`),
"/pki/root/sign-self-issued": regexp.MustCompile(`^/pki/root/sign-self-issued$`),
"/sys/audit": regexp.MustCompile(`^/sys/audit$`),
"/sys/audit/{path}": regexp.MustCompile(`^/sys/audit/.+$`),
"/sys/auth/{path}": regexp.MustCompile(`^/sys/auth/.+$`),
"/sys/auth/{path}/tune": regexp.MustCompile(`^/sys/auth/.+/tune$`),
"/sys/config/auditing/request-headers": regexp.MustCompile(`^/sys/config/auditing/request-headers$`),
"/sys/config/auditing/request-headers/{header}": regexp.MustCompile(`^/sys/config/auditing/request-headers/.+$`),
"/sys/config/cors": regexp.MustCompile(`^/sys/config/cors$`),
"/sys/config/ui/headers/": regexp.MustCompile(`^/sys/config/ui/headers/$`),
"/sys/config/ui/headers/{header}": regexp.MustCompile(`^/sys/config/ui/headers/.+$`),
"/sys/leases": regexp.MustCompile(`^/sys/leases$`),
"/sys/leases/lookup/": regexp.MustCompile(`^/sys/leases/lookup/$`),
"/sys/leases/lookup/{prefix}": regexp.MustCompile(`^/sys/leases/lookup/.+$`),
"/sys/leases/revoke-force/{prefix}": regexp.MustCompile(`^/sys/leases/revoke-force/.+$`),
"/sys/leases/revoke-prefix/{prefix}": regexp.MustCompile(`^/sys/leases/revoke-prefix/.+$`),
"/sys/plugins/catalog/{name}": regexp.MustCompile(`^/sys/plugins/catalog/[^/]+$`),
"/sys/plugins/catalog/{type}": regexp.MustCompile(`^/sys/plugins/catalog/[\w-]+$`),
"/sys/plugins/catalog/{type}/{name}": regexp.MustCompile(`^/sys/plugins/catalog/[\w-]+/[^/]+$`),
"/sys/raw": regexp.MustCompile(`^/sys/raw$`),
"/sys/raw/{path}": regexp.MustCompile(`^/sys/raw/.+$`),
"/sys/remount": regexp.MustCompile(`^/sys/remount$`),
"/sys/revoke-force/{prefix}": regexp.MustCompile(`^/sys/revoke-force/.+$`),
"/sys/revoke-prefix/{prefix}": regexp.MustCompile(`^/sys/revoke-prefix/.+$`),
"/sys/rotate": regexp.MustCompile(`^/sys/rotate$`),
}
)

// PluginAPIClientMeta is a helper that plugins can use to configure TLS connections
Expand Down Expand Up @@ -192,3 +228,28 @@ func VaultPluginTLSProviderContext(ctx context.Context, apiTLSConfig *TLSConfig)
return tlsConfig, nil
}
}

func SudoPaths() map[string]*regexp.Regexp {
return sudoPaths
}

// Determine whether the given path requires the sudo capability
func IsSudoPath(path string) bool {
// Return early if the path is any of the non-templated sudo paths.
if _, ok := sudoPaths[path]; ok {
return true
}

// Some sudo paths have templated fields in them.
// (e.g. /sys/revoke-prefix/{prefix})
// The values in the sudoPaths map are actually regular expressions,
// so we can check if our path matches against them.
for _, sudoPathRegexp := range sudoPaths {
match := sudoPathRegexp.MatchString(path)
if match {
return true
}
}

return false
}
Loading

0 comments on commit 7089487

Please sign in to comment.