diff --git a/api/client.go b/api/client.go index 89160cd9a368..efdf033af235 100644 --- a/api/client.go +++ b/api/client.go @@ -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. @@ -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 { @@ -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() @@ -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 { @@ -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 { @@ -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 diff --git a/api/client_test.go b/api/client_test.go index 58b797d3489a..383932b9e73e 100644 --- a/api/client_test.go +++ b/api/client_test.go @@ -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 { diff --git a/api/output_policy.go b/api/output_policy.go new file mode 100644 index 000000000000..85d1617e5e94 --- /dev/null +++ b/api/output_policy.go @@ -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 +} diff --git a/api/output_string.go b/api/output_string.go index 9129ea0c3f77..b8c396ebc05d 100644 --- a/api/output_string.go +++ b/api/output_string.go @@ -19,58 +19,68 @@ 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) } } @@ -78,15 +88,8 @@ func (d *OutputStringError) parseRequest() { // 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 } diff --git a/api/plugin_helpers.go b/api/plugin_helpers.go index e7da60cc55da..dd2f8d02353f 100644 --- a/api/plugin_helpers.go +++ b/api/plugin_helpers.go @@ -9,6 +9,7 @@ import ( "flag" "net/url" "os" + "regexp" squarejwt "gopkg.in/square/go-jose.v2/jwt" @@ -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 @@ -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 +} diff --git a/api/plugin_helpers_test.go b/api/plugin_helpers_test.go new file mode 100644 index 000000000000..453720ea7a5a --- /dev/null +++ b/api/plugin_helpers_test.go @@ -0,0 +1,56 @@ +package api + +import "testing" + +func TestIsSudoPath(t *testing.T) { + t.Parallel() + + testCases := []struct { + path string + expected bool + }{ + { + "/not/in/sudo/paths/list", + false, + }, + { + "/sys/raw/single-node-path", + true, + }, + { + "/sys/raw/multiple/nodes/path", + true, + }, + { + "/sys/raw/WEIRD(but_still_valid!)p4Th?🗿笑", + true, + }, + { + "/sys/auth/path/in/middle/tune", + true, + }, + { + "/sys/plugins/catalog/some-type", + true, + }, + { + "/sys/plugins/catalog/some/type/or/name/with/slashes", + false, + }, + { + "/sys/plugins/catalog/some-type/some-name", + true, + }, + { + "/sys/plugins/catalog/some-type/some/name/with/slashes", + false, + }, + } + + for _, tc := range testCases { + result := IsSudoPath(tc.path) + if result != tc.expected { + t.Fatalf("expected api.IsSudoPath to return %v for path %s but it returned %v", tc.expected, tc.path, result) + } + } +} diff --git a/changelog/14899.txt b/changelog/14899.txt new file mode 100644 index 000000000000..6c943dfd9965 --- /dev/null +++ b/changelog/14899.txt @@ -0,0 +1,3 @@ +```release-note:feature +api/command: Global -output-policy flag to determine minimum required policy HCL for a given operation +``` \ No newline at end of file diff --git a/command/base.go b/command/base.go index 0363db3a0b3f..903c5dc3effe 100644 --- a/command/base.go +++ b/command/base.go @@ -57,6 +57,7 @@ type BaseCommand struct { flagFormat string flagField string flagOutputCurlString bool + flagOutputPolicy bool flagNonInteractive bool flagMFA []string @@ -92,6 +93,9 @@ func (c *BaseCommand) Client() (*api.Client, error) { if c.flagOutputCurlString { config.OutputCurlString = c.flagOutputCurlString } + if c.flagOutputPolicy { + config.OutputPolicy = c.flagOutputPolicy + } // If we need custom TLS configuration, then set it if c.flagCACert != "" || c.flagCAPath != "" || c.flagClientCert != "" || @@ -458,6 +462,14 @@ func (c *BaseCommand) flagSet(bit FlagSetBit) *FlagSets { "command string and exit.", }) + f.BoolVar(&BoolVar{ + Name: "output-policy", + Target: &c.flagOutputPolicy, + Default: false, + Usage: "Instead of executing the request, print an example HCL " + + "policy that would be required to run this command, and exit.", + }) + f.StringVar(&StringVar{ Name: "unlock-key", Target: &c.flagUnlockKey, diff --git a/command/kv_helpers.go b/command/kv_helpers.go index 7a2bf9f8d962..b362c3bb0713 100644 --- a/command/kv_helpers.go +++ b/command/kv_helpers.go @@ -51,6 +51,9 @@ func kvPreflightVersionRequest(client *api.Client, path string) (string, int, er currentOutputCurlString := client.OutputCurlString() client.SetOutputCurlString(false) defer client.SetOutputCurlString(currentOutputCurlString) + currentOutputPolicy := client.OutputPolicy() + client.SetOutputPolicy(false) + defer client.SetOutputPolicy(currentOutputPolicy) r := client.NewRequest("GET", "/v1/sys/internal/ui/mounts/"+path) resp, err := client.RawRequest(r) @@ -60,8 +63,20 @@ func kvPreflightVersionRequest(client *api.Client, path string) (string, int, er if err != nil { // If we get a 404 we are using an older version of vault, default to // version 1 - if resp != nil && resp.StatusCode == 404 { - return "", 1, nil + if resp != nil { + if resp.StatusCode == 404 { + return "", 1, nil + } + + // if the original request had the -output-curl-string or -output-policy flag, + if (currentOutputCurlString || currentOutputPolicy) && resp.StatusCode == 403 { + // we provide a more helpful error for the user, + // who may not understand why the flag isn't working. + err = fmt.Errorf( + `This output flag requires the success of a preflight request +to determine the version of a KV secrets engine. Please +re-run this command with a token with read access to %s`, path) + } } return "", 0, err diff --git a/command/kv_patch.go b/command/kv_patch.go index 5f813fb15730..1134248f5d7c 100644 --- a/command/kv_patch.go +++ b/command/kv_patch.go @@ -242,12 +242,15 @@ func (c *KVPatchCommand) readThenWrite(client *api.Client, path string, newData // Note that we don't want to see curl output for the read request. curOutputCurl := client.OutputCurlString() client.SetOutputCurlString(false) + outputPolicy := client.OutputPolicy() + client.SetOutputPolicy(false) secret, err := kvReadRequest(client, path, nil) - client.SetOutputCurlString(curOutputCurl) if err != nil { c.UI.Error(fmt.Sprintf("Error doing pre-read at %s: %s", path, err)) return nil, 2 } + client.SetOutputCurlString(curOutputCurl) + client.SetOutputPolicy(outputPolicy) // Make sure a value already exists if secret == nil || secret.Data == nil { diff --git a/command/main.go b/command/main.go index ffce18109378..95d9c9bff651 100644 --- a/command/main.go +++ b/command/main.go @@ -5,7 +5,6 @@ import ( "bytes" "fmt" "io" - "io/ioutil" "os" "sort" "strings" @@ -25,7 +24,7 @@ type VaultUI struct { // setupEnv parses args and may replace them and sets some env vars to known // values based on format options -func setupEnv(args []string) (retArgs []string, format string, outputCurlString bool) { +func setupEnv(args []string) (retArgs []string, format string, outputCurlString bool, outputPolicy bool) { var nextArgFormat bool for _, arg := range args { @@ -49,6 +48,11 @@ func setupEnv(args []string) (retArgs []string, format string, outputCurlString continue } + if arg == "-output-policy" { + outputPolicy = true + continue + } + // Parse a given flag here, which overrides the env var if strings.HasPrefix(arg, "--format=") { format = strings.TrimPrefix(arg, "--format=") @@ -73,7 +77,7 @@ func setupEnv(args []string) (retArgs []string, format string, outputCurlString format = "table" } - return args, format, outputCurlString + return args, format, outputCurlString, outputPolicy } type RunOptions struct { @@ -97,7 +101,8 @@ func RunCustom(args []string, runOpts *RunOptions) int { var format string var outputCurlString bool - args, format, outputCurlString = setupEnv(args) + var outputPolicy bool + args, format, outputCurlString, outputPolicy = setupEnv(args) // Don't use color if disabled useColor := true @@ -126,8 +131,8 @@ func RunCustom(args []string, runOpts *RunOptions) int { } uiErrWriter := runOpts.Stderr - if outputCurlString { - uiErrWriter = ioutil.Discard + if outputCurlString || outputPolicy { + uiErrWriter = &bytes.Buffer{} } ui := &VaultUI{ @@ -179,25 +184,9 @@ func RunCustom(args []string, runOpts *RunOptions) int { exitCode, err := cli.Run() if outputCurlString { - if exitCode == 0 { - fmt.Fprint(runOpts.Stderr, "Could not generate cURL command") - return 1 - } else { - if api.LastOutputStringError == nil { - if exitCode == 127 { - // Usage, just pass it through - return exitCode - } - fmt.Fprint(runOpts.Stderr, "cURL command not set by API operation; run without -output-curl-string to see the generated error\n") - return exitCode - } - if api.LastOutputStringError.Error() != api.ErrOutputStringRequest { - runOpts.Stdout.Write([]byte(fmt.Sprintf("Error creating request string: %s\n", api.LastOutputStringError.Error()))) - return 1 - } - runOpts.Stdout.Write([]byte(fmt.Sprintf("%s\n", api.LastOutputStringError.CurlString()))) - return 0 - } + return generateCurlString(exitCode, runOpts, uiErrWriter.(*bytes.Buffer)) + } else if outputPolicy { + return generatePolicy(exitCode, runOpts, uiErrWriter.(*bytes.Buffer)) } else if err != nil { fmt.Fprintf(runOpts.Stderr, "Error executing CLI: %s\n", err.Error()) return 1 @@ -264,3 +253,55 @@ func printCommand(w io.Writer, name string, cmdFn cli.CommandFactory) { } fmt.Fprintf(w, " %s\t%s\n", name, cmd.Synopsis()) } + +func generateCurlString(exitCode int, runOpts *RunOptions, preParsingErrBuf *bytes.Buffer) int { + if exitCode == 0 { + fmt.Fprint(runOpts.Stderr, "Could not generate cURL command") + return 1 + } + + if api.LastOutputStringError == nil { + if exitCode == 127 { + // Usage, just pass it through + return exitCode + } + runOpts.Stderr.Write(preParsingErrBuf.Bytes()) + runOpts.Stderr.Write([]byte("Unable to generate cURL string from command\n")) + return exitCode + } + + cs, err := api.LastOutputStringError.CurlString() + if err != nil { + runOpts.Stderr.Write([]byte(fmt.Sprintf("Error creating request string: %s\n", err))) + return 1 + } + + runOpts.Stdout.Write([]byte(fmt.Sprintf("%s\n", cs))) + return 0 +} + +func generatePolicy(exitCode int, runOpts *RunOptions, preParsingErrBuf *bytes.Buffer) int { + if exitCode == 0 { + fmt.Fprint(runOpts.Stderr, "Could not generate policy") + return 1 + } + + if api.LastOutputPolicyError == nil { + if exitCode == 127 { + // Usage, just pass it through + return exitCode + } + runOpts.Stderr.Write(preParsingErrBuf.Bytes()) + runOpts.Stderr.Write([]byte("Unable to generate policy from command\n")) + return exitCode + } + + hcl, err := api.LastOutputPolicyError.HCLString() + if err != nil { + runOpts.Stderr.Write([]byte(fmt.Sprintf("Error assembling policy HCL: %s\n", err))) + return 1 + } + + runOpts.Stdout.Write([]byte(fmt.Sprintf("%s\n", hcl))) + return 0 +} diff --git a/command/operator_unseal_test.go b/command/operator_unseal_test.go index 06d618cacf72..6ce05b61a00a 100644 --- a/command/operator_unseal_test.go +++ b/command/operator_unseal_test.go @@ -167,7 +167,7 @@ func TestOperatorUnsealCommand_Format(t *testing.T) { Client: client, } - args, format, _ := setupEnv([]string{"operator", "unseal", "-format", "json"}) + args, format, _, _ := setupEnv([]string{"operator", "unseal", "-format", "json"}) if format != "json" { t.Fatalf("expected %q, got %q", "json", format) } diff --git a/command/operator_usage.go b/command/operator_usage.go index cce3c65087c4..1df42091ccc3 100644 --- a/command/operator_usage.go +++ b/command/operator_usage.go @@ -146,7 +146,7 @@ func (c *OperatorUsageCommand) Run(args []string) int { // queries can be answered; if there's an error, just fall back to // reporting that the response is empty. func (c *OperatorUsageCommand) noReportAvailable(client *api.Client) bool { - if c.flagOutputCurlString { + if c.flagOutputCurlString || c.flagOutputPolicy { // Don't mess up the original query string return false } diff --git a/command/version_history_test.go b/command/version_history_test.go index 26d4ef3c2582..c79c59ad43be 100644 --- a/command/version_history_test.go +++ b/command/version_history_test.go @@ -57,7 +57,7 @@ func TestVersionHistoryCommand_JsonOutput(t *testing.T) { Client: client, } - args, format, _ := setupEnv([]string{"version-history", "-format", "json"}) + args, format, _, _ := setupEnv([]string{"version-history", "-format", "json"}) if format != "json" { t.Fatalf("expected format to be %q, actual %q", "json", format) } diff --git a/vault/external_tests/api/sudo_paths_test.go b/vault/external_tests/api/sudo_paths_test.go new file mode 100644 index 000000000000..778b23ac1ad6 --- /dev/null +++ b/vault/external_tests/api/sudo_paths_test.go @@ -0,0 +1,133 @@ +package api + +import ( + "fmt" + "testing" + + log "github.com/hashicorp/go-hclog" + "github.com/hashicorp/vault/api" + "github.com/hashicorp/vault/audit" + auditFile "github.com/hashicorp/vault/builtin/audit/file" + credUserpass "github.com/hashicorp/vault/builtin/credential/userpass" + "github.com/hashicorp/vault/builtin/logical/database" + "github.com/hashicorp/vault/builtin/logical/pki" + "github.com/hashicorp/vault/builtin/logical/transit" + "github.com/hashicorp/vault/helper/builtinplugins" + "github.com/hashicorp/vault/sdk/helper/jsonutil" + "github.com/hashicorp/vault/sdk/logical" + "github.com/hashicorp/vault/vault" +) + +const sudoKey = "x-vault-sudo" + +// Tests that the static list of sudo paths in the api package matches what's in the current OpenAPI spec. +func TestSudoPaths(t *testing.T) { + t.Parallel() + + coreConfig := &vault.CoreConfig{ + DisableMlock: true, + DisableCache: true, + EnableRaw: true, + Logger: log.NewNullLogger(), + CredentialBackends: map[string]logical.Factory{ + "userpass": credUserpass.Factory, + }, + AuditBackends: map[string]audit.Factory{ + "file": auditFile.Factory, + }, + LogicalBackends: map[string]logical.Factory{ + "database": database.Factory, + "generic-leased": vault.LeasedPassthroughBackendFactory, + "pki": pki.Factory, + "transit": transit.Factory, + }, + BuiltinRegistry: builtinplugins.Registry, + } + client, _, closer := testVaultServerCoreConfig(t, coreConfig) + defer closer() + + for credBackendName := range coreConfig.CredentialBackends { + err := client.Sys().EnableAuthWithOptions(credBackendName, &api.EnableAuthOptions{ + Type: credBackendName, + }) + if err != nil { + t.Fatalf("error enabling auth backend for test: %v", err) + } + } + + for logicalBackendName := range coreConfig.LogicalBackends { + err := client.Sys().Mount(logicalBackendName, &api.MountInput{ + Type: logicalBackendName, + }) + if err != nil { + t.Fatalf("error enabling logical backend for test: %v", err) + } + } + + sudoPathsFromSpec, err := getSudoPathsFromSpec(client) + if err != nil { + t.Fatalf("error getting list of paths that require sudo from OpenAPI endpoint: %v", err) + } + + sudoPathsInCode := api.SudoPaths() + + // check for missing or superfluous paths + for path := range sudoPathsInCode { + if _, ok := sudoPathsFromSpec[path]; !ok { + t.Fatalf( + "A path in the static list of sudo paths in the api module is "+ + "missing from the OpenAPI spec (%s). Please reconcile the two "+ + "accordingly.", path) + } + } + for path := range sudoPathsFromSpec { + if _, ok := sudoPathsInCode[path]; !ok { + t.Fatalf( + "A path in the OpenAPI spec is missing from the static list of "+ + "sudo paths in the api module (%s). Please reconcile the two "+ + "accordingly.", path) + } + } +} + +func getSudoPathsFromSpec(client *api.Client) (map[string]struct{}, error) { + r := client.NewRequest("GET", "/v1/sys/internal/specs/openapi") + resp, err := client.RawRequest(r) + if err != nil { + return nil, fmt.Errorf("unable to retrieve sudo endpoints: %v", err) + } + if resp != nil { + defer resp.Body.Close() + } + + oasInfo := make(map[string]interface{}) + if err := jsonutil.DecodeJSONFromReader(resp.Body, &oasInfo); err != nil { + return nil, fmt.Errorf("unable to decode JSON from OpenAPI response: %v", err) + } + + paths, ok := oasInfo["paths"] + if !ok { + return nil, fmt.Errorf("OpenAPI response did not include paths") + } + + pathsMap, ok := paths.(map[string]interface{}) + if !ok { + return nil, fmt.Errorf("OpenAPI response did not return valid paths") + } + + sudoPaths := make(map[string]struct{}) + for pathName, pathInfo := range pathsMap { + pathInfoMap, ok := pathInfo.(map[string]interface{}) + if !ok { + continue + } + + if sudo, ok := pathInfoMap[sudoKey]; ok { + if sudo == true { + sudoPaths[pathName] = struct{}{} + } + } + } + + return sudoPaths, nil +}