Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Global flag that outputs minimum policy HCL required for an operation #14899

Merged
merged 42 commits into from
Apr 27, 2022
Merged
Show file tree
Hide file tree
Changes from 12 commits
Commits
Show all changes
42 commits
Select commit Hold shift + click to select a range
178e45c
WIP: output policy
digivava Feb 24, 2022
43a4552
Merge branch 'main' into digivava/output-policy
digivava Feb 28, 2022
f990174
Outputs example policy HCL for given request
digivava Mar 3, 2022
7b85ee3
Merge branch 'main' into digivava/output-policy
digivava Mar 21, 2022
ca8965b
Simplify conditional
digivava Mar 22, 2022
bd8acc4
Add PATCH capability
digivava Mar 23, 2022
ac41617
Fixed merge conflict
digivava Apr 1, 2022
d98c73b
Use OpenAPI spec and regex patterns to determine if path is sudo
digivava Apr 4, 2022
651dd90
Add test for isSudoPath
digivava Apr 4, 2022
556b690
Add changelog
digivava Apr 4, 2022
75ea6eb
Fix broken CLI tests
digivava Apr 5, 2022
37ff76b
Add output-policy to client cloning code
digivava Apr 5, 2022
584bc78
Smaller fixes from PR comments
digivava Apr 5, 2022
17fc209
Clone client instead of saving and restoring custom values
digivava Apr 6, 2022
96a611b
Fix test
digivava Apr 6, 2022
4ba6890
Address comments
digivava Apr 6, 2022
36c2276
Don't unset output-policy flag on KV requests otherwise the preflight…
digivava Apr 7, 2022
9da6de6
Merge from main
digivava Apr 8, 2022
1d80573
Print errors saved in buffer from preflight KV requests
digivava Apr 18, 2022
e450b9c
Unescape characters in request URL
digivava Apr 18, 2022
79f9bf8
Merge from main
digivava Apr 18, 2022
bfb755f
Rename methods and properties to improve readability
digivava Apr 20, 2022
29dd10b
Put KV-specificness at front of KV-specific error
digivava Apr 20, 2022
93b0373
Simplify logic by doing more direct returns of strings and errors
digivava Apr 20, 2022
c3f9936
Merge branch 'main' into digivava/output-policy
digivava Apr 25, 2022
13d09d8
Use precompiled regexes and move OpenAPI call to tests
digivava Apr 25, 2022
922e550
Merge branch 'main' into digivava/output-policy
digivava Apr 25, 2022
4cdf1ea
Merge branch 'main' into digivava/output-policy-static-list
digivava Apr 25, 2022
987c0b6
Remove commented out code
digivava Apr 25, 2022
e685dbb
Remove legacy MFA paths
digivava Apr 25, 2022
6fc4b6a
Merge branch 'digivava/output-policy' into digivava/output-policy-sta…
digivava Apr 25, 2022
2293944
Remove unnecessary use of client
digivava Apr 25, 2022
cc43942
Move sudo paths map to plugin helper
digivava Apr 25, 2022
42c4025
Remove unused error return
digivava Apr 25, 2022
32fe843
Add explanatory comment
digivava Apr 25, 2022
d44f61f
Remove need to pass in address
digivava Apr 25, 2022
7327e9e
Make {name} regex less greedy
digivava Apr 26, 2022
47f0b05
Use method and path instead of info from retryablerequest
digivava Apr 26, 2022
6107c62
Add test for IsSudoPaths, use more idiomatic naming
digivava Apr 26, 2022
384e2e8
Use precompiled regexes and move OpenAPI call to tests (#15170)
digivava Apr 26, 2022
4b3ad0c
Make stderr writing more obvious, fix nil pointer deref
digivava Apr 27, 2022
d544236
Merge readability and nil pointer fixes
digivava Apr 27, 2022
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 42 additions & 1 deletion api/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,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 @@ -577,6 +585,7 @@ func (c *Client) CloneConfig() *Config {
newConfig.Logger = c.config.Logger
newConfig.Limiter = c.config.Limiter
newConfig.OutputCurlString = c.config.OutputCurlString
newConfig.OutputPolicy = c.config.OutputPolicy
newConfig.SRVLookup = c.config.SRVLookup
newConfig.CloneHeaders = c.config.CloneHeaders
newConfig.CloneToken = c.config.CloneToken
Expand Down Expand Up @@ -768,6 +777,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 @@ -1001,6 +1028,7 @@ func (c *Client) clone(cloneHeaders bool) (*Client, error) {
Logger: config.Logger,
Limiter: config.Limiter,
OutputCurlString: config.OutputCurlString,
OutputPolicy: config.OutputPolicy,
AgentAddress: config.AgentAddress,
SRVLookup: config.SRVLookup,
CloneHeaders: config.CloneHeaders,
Expand Down Expand Up @@ -1132,6 +1160,7 @@ func (c *Client) rawRequestWithContext(ctx context.Context, r *Request) (*Respon
backoff := c.config.Backoff
httpClient := c.config.HttpClient
outputCurlString := c.config.OutputCurlString
outputPolicy := c.config.OutputPolicy
logger := c.config.Logger
c.config.modifyLock.RUnlock()

Expand Down Expand Up @@ -1176,6 +1205,14 @@ START:
return nil, LastOutputStringError
}

if outputPolicy {
LastOutputPolicyError = &OutputPolicyError{
Request: req,
VaultClient: c,
}
return nil, LastOutputPolicyError
}

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

if backoff == nil {
Expand Down Expand Up @@ -1268,6 +1305,7 @@ 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
if c.headers != nil {
for header, vals := range c.headers {
for _, val := range vals {
Expand All @@ -1278,10 +1316,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 relies on the request type to be retryable.Request
digivava marked this conversation as resolved.
Show resolved Hide resolved
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 @@ -477,6 +477,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
197 changes: 197 additions & 0 deletions api/output_policy.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
package api
digivava marked this conversation as resolved.
Show resolved Hide resolved

import (
"bytes"
"fmt"
"regexp"
"strings"

retryablehttp "github.com/hashicorp/go-retryablehttp"
"github.com/hashicorp/vault/sdk/helper/jsonutil"
)

const (
ErrOutputPolicyRequest = "output policy request"
)

var LastOutputPolicyError *OutputPolicyError
digivava marked this conversation as resolved.
Show resolved Hide resolved

type OutputPolicyError struct {
*retryablehttp.Request
VaultClient *Client
parsingError error
parsedHCLString string
}

func (d *OutputPolicyError) Error() string {
if d.parsedHCLString == "" {
d.parseRequest()
if d.parsingError != nil {
return d.parsingError.Error()
}
}
digivava marked this conversation as resolved.
Show resolved Hide resolved

return ErrOutputPolicyRequest
}

// Builds a sample policy document from the request
func (d *OutputPolicyError) parseRequest() {
averche marked this conversation as resolved.
Show resolved Hide resolved
capabilities := []string{}
digivava marked this conversation as resolved.
Show resolved Hide resolved
switch d.Request.Method {
case "GET":
digivava marked this conversation as resolved.
Show resolved Hide resolved
capabilities = append(capabilities, "read")
case "LIST":
capabilities = append(capabilities, "list")
case "POST", "PUT":
capabilities = append(capabilities, "create")
capabilities = append(capabilities, "update")
digivava marked this conversation as resolved.
Show resolved Hide resolved
case "PATCH":
capabilities = append(capabilities, "patch")
case "DELETE":
capabilities = append(capabilities, "delete")
}

// trim the Vault address and v1 from the front of the path
url := d.Request.URL.String()
apiAddrPrefix := fmt.Sprintf("%sv1/", d.VaultClient.config.Address)
path := strings.Trim(url, apiAddrPrefix)

// determine whether to add sudo capability
needsSudo, err := isSudoPath(d.VaultClient, path)
if err != nil {
d.parsingError = err
return
}
if needsSudo {
capabilities = append(capabilities, "sudo")
}

capStr := strings.Join(capabilities, `", "`)
d.parsedHCLString = fmt.Sprintf(
digivava marked this conversation as resolved.
Show resolved Hide resolved
`path "%s" {
capabilities = ["%s"]
}`, path, capStr)
}

func (d *OutputPolicyError) HCLString() string {
if d.parsedHCLString == "" {
d.parseRequest()
}
return d.parsedHCLString
}

// Determine whether the given path requires the sudo capability
func isSudoPath(client *Client, path string) (bool, error) {
sudoPaths, err := getSudoPaths(client)
digivava marked this conversation as resolved.
Show resolved Hide resolved
if err != nil {
return false, fmt.Errorf("unable to retrieve list of paths that require sudo capability: %v", err)
}
if sudoPaths == nil || len(sudoPaths) < 1 {
// OpenAPI spec did not return any paths that require sudo for
// some reason, but the user probably still shouldn't see an error.
return false, nil
}
digivava marked this conversation as resolved.
Show resolved Hide resolved

// Return early if the path is clearly one of the sudo paths.
if _, ok := sudoPaths[path]; ok {
return true, nil
}

// Some sudo paths have templated fields in them.
// (e.g. sys/revoke-prefix/{prefix})
// The keys in the sudoPaths map are actually regular expressions,
// so we can check if our path matches against them.
for sudoPath := range sudoPaths {
r, err := regexp.Compile(fmt.Sprintf("^%s$", sudoPath))
if err != nil {
continue
}

match := r.Match([]byte(fmt.Sprintf("/%s", path))) // the OpenAPI response has a / in front of each path
if match {
return true, nil
}
}

return false, nil
}

func getSudoPaths(client *Client) (map[string]bool, error) {
// We don't want to use a wrapping call or any special flags here
// so save any custom value and restore after.
currentWrappingLookupFunc := client.CurrentWrappingLookupFunc()
client.SetWrappingLookupFunc(nil)
defer client.SetWrappingLookupFunc(currentWrappingLookupFunc)
currentOutputCurlString := client.OutputCurlString()
client.SetOutputCurlString(false)
defer client.SetOutputCurlString(currentOutputCurlString)
currentOutputPolicy := client.OutputPolicy()
client.SetOutputPolicy(false)
defer client.SetOutputPolicy(currentOutputPolicy)
digivava marked this conversation as resolved.
Show resolved Hide resolved
digivava marked this conversation as resolved.
Show resolved Hide resolved

r := client.NewRequest("GET", "/v1/sys/internal/specs/openapi")
resp, err := client.RawRequest(r)
if resp != nil {
defer resp.Body.Close()
}
digivava marked this conversation as resolved.
Show resolved Hide resolved
if err != nil {
return nil, fmt.Errorf("unable to retrieve sudo endpoints: %v", err)
}

var buf bytes.Buffer
_, err = buf.ReadFrom(resp.Body)
if err != nil {
return nil, fmt.Errorf("unable to read response body from OpenAPI: %v", err)
}
if buf.Len() == 0 {
return nil, fmt.Errorf("OpenAPI response had no content")
}

oasInfo := make(map[string]interface{})
if err := jsonutil.DecodeJSONFromReader(&buf, &oasInfo); err != nil {
return nil, fmt.Errorf("unable to decode JSON from OpenAPI response: %v", err)
}
digivava marked this conversation as resolved.
Show resolved Hide resolved

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]bool) // this could be a slice, but we're just making it a map so we can do quick lookups for the paths that don't have any templating
digivava marked this conversation as resolved.
Show resolved Hide resolved
for pathName, pathInfo := range pathsMap {
pathInfoMap, ok := pathInfo.(map[string]interface{})
if !ok {
continue
}

if sudo, ok := pathInfoMap["x-vault-sudo"]; ok {
if sudo == true {
// Since many paths have templated fields like {name},
// our list of sudo paths will actually be a list of
// regular expressions that we can match against.
pathRegex := buildPathRegexp(pathName)
sudoPaths[pathRegex] = true
}
}
}

return sudoPaths, nil
}

// Replaces any template fields in a path with the characters ".+" so that
// we can later allow any characters to match those fields.
func buildPathRegexp(pathName string) string {
digivava marked this conversation as resolved.
Show resolved Hide resolved
templateFields := []string{"{path}", "{header}", "{prefix}", "{name}", "{type}"}
pathWithRegexPatterns := pathName
for _, field := range templateFields {
r, _ := regexp.Compile(field)
pathWithRegexPatterns = r.ReplaceAllString(pathWithRegexPatterns, ".+")
}

return pathWithRegexPatterns
}
91 changes: 91 additions & 0 deletions api/output_policy_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
package api

import (
"net/http"
"strings"
"testing"
)

func TestIsSudoPath(t *testing.T) {
handler := func(w http.ResponseWriter, req *http.Request) {
w.Write([]byte(
`{
"paths": {
"/sudo/path": {
"x-vault-sudo": true
},
"/not/a/sudo/path": {
"x-vault-sudo": false
},
"not/a/sudo/path/either": {},
"/sudo/path/with/template/{name}": {
"x-vault-sudo": true
},
"/sudo/path/with/nested/template/{prefix}/too": {
"x-vault-sudo": true
},
"/sudo/path/with/multiple/templates/{type}/{header}": {
"x-vault-sudo": true
}
}
}`,
))
}

config, ln := testHTTPServer(t, http.HandlerFunc(handler))
defer ln.Close()

config.Address = strings.ReplaceAll(config.Address, "127.0.0.1", "localhost")
digivava marked this conversation as resolved.
Show resolved Hide resolved
client, err := NewClient(config)
if err != nil {
t.Fatalf("err: %s", err)
}

client.SetToken("foo")

client.SetOutputPolicy(true)

testCases := []struct {
path string
expected bool
}{
{
"secret/foo", // not in the openAPI response at all
false,
},
{
"sudo/path",
true,
},
{
"not/a/sudo/path",
false,
},
{
"not/a/sudo/path/either",
false,
},
{
"sudo/path/with/template/foo",
true,
},
{
"sudo/path/with/nested/template/foo/too",
true,
},
{
"sudo/path/with/multiple/templates/foo/bar",
true,
},
}

for _, tc := range testCases {
res, err := isSudoPath(client, tc.path)
if err != nil {
t.Fatalf("error checking if path is sudo: %v", err)
}
if res != tc.expected {
t.Fatalf("expected isSudoPath to return %v for path %s but it returned %v", tc.expected, tc.path, res)
}
}
}
digivava marked this conversation as resolved.
Show resolved Hide resolved
Loading