diff --git a/api/sys_capabilities.go b/api/sys_capabilities.go new file mode 100644 index 0000000000..1640a2fc9b --- /dev/null +++ b/api/sys_capabilities.go @@ -0,0 +1,48 @@ +package api + +func (c *Sys) CapabilitiesSelf(path string) ([]string, error) { + body := map[string]string{ + "path": path, + } + + r := c.c.NewRequest("POST", "/v1/sys/capabilities-self") + if err := r.SetJSONBody(body); err != nil { + return nil, err + } + + resp, err := c.c.RawRequest(r) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + var result CapabilitiesResponse + err = resp.DecodeJSON(&result) + return result.Capabilities, err +} + +func (c *Sys) Capabilities(token, path string) ([]string, error) { + body := map[string]string{ + "token": token, + "path": path, + } + + r := c.c.NewRequest("POST", "/v1/sys/capabilities") + if err := r.SetJSONBody(body); err != nil { + return nil, err + } + + resp, err := c.c.RawRequest(r) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + var result CapabilitiesResponse + err = resp.DecodeJSON(&result) + return result.Capabilities, err +} + +type CapabilitiesResponse struct { + Capabilities []string `json:"capabilities"` +} diff --git a/cli/commands.go b/cli/commands.go index 1f5b89f911..ec6cb27359 100644 --- a/cli/commands.go +++ b/cli/commands.go @@ -290,6 +290,12 @@ func Commands(metaPtr *command.Meta) map[string]cli.CommandFactory { }, nil }, + "capabilities": func() (cli.Command, error) { + return &command.CapabilitiesCommand{ + Meta: meta, + }, nil + }, + "version": func() (cli.Command, error) { versionInfo := version.GetVersion() diff --git a/command/capabilities.go b/command/capabilities.go new file mode 100644 index 0000000000..b33a6914a2 --- /dev/null +++ b/command/capabilities.go @@ -0,0 +1,86 @@ +package command + +import ( + "fmt" + "strings" +) + +// CapabilitiesCommand is a Command that enables a new endpoint. +type CapabilitiesCommand struct { + Meta +} + +func (c *CapabilitiesCommand) Run(args []string) int { + flags := c.Meta.FlagSet("capabilities", FlagSetDefault) + flags.Usage = func() { c.Ui.Error(c.Help()) } + if err := flags.Parse(args); err != nil { + return 1 + } + + args = flags.Args() + if len(args) > 2 { + flags.Usage() + c.Ui.Error(fmt.Sprintf( + "\ncapabilities expects at most two arguments")) + return 1 + } + + var token string + var path string + switch { + case len(args) == 1: + path = args[0] + case len(args) == 2: + token = args[0] + path = args[1] + default: + flags.Usage() + c.Ui.Error(fmt.Sprintf("\ncapabilities expects at least one argument")) + return 1 + } + + client, err := c.Client() + if err != nil { + c.Ui.Error(fmt.Sprintf( + "Error initializing client: %s", err)) + return 2 + } + + var capabilities []string + if token == "" { + capabilities, err = client.Sys().CapabilitiesSelf(path) + } else { + capabilities, err = client.Sys().Capabilities(token, path) + } + if err != nil { + c.Ui.Error(fmt.Sprintf( + "Error retrieving capabilities: %s", err)) + return 1 + } + + c.Ui.Output(fmt.Sprintf("Capabilities: %s", capabilities)) + return 0 +} + +func (c *CapabilitiesCommand) Synopsis() string { + return "Fetch the capabilities of a token on a given path" +} + +func (c *CapabilitiesCommand) Help() string { + helpText := ` +Usage: vault capabilities [options] [token] path + + Fetch the capabilities of a token on a given path. + If a token is provided as an argument, the '/sys/capabilities' endpoint will be invoked + with the given token; otherwise the '/sys/capabilities-self' endpoint will be invoked + with the client token. + + If a token does not have any capability on a given path, or if any of the policies + belonging to the token explicitly have ["deny"] capability, or if the argument path + is invalid, this command will respond with a ["deny"]. + +General Options: + + ` + generalOptionsUsage() + return strings.TrimSpace(helpText) +} diff --git a/command/capabilities_test.go b/command/capabilities_test.go new file mode 100644 index 0000000000..d300ff6b67 --- /dev/null +++ b/command/capabilities_test.go @@ -0,0 +1,44 @@ +package command + +import ( + "testing" + + "github.com/hashicorp/vault/http" + "github.com/hashicorp/vault/vault" + "github.com/mitchellh/cli" +) + +func TestCapabilities_Basic(t *testing.T) { + core, _, token := vault.TestCoreUnsealed(t) + ln, addr := http.TestServer(t, core) + defer ln.Close() + ui := new(cli.MockUi) + c := &CapabilitiesCommand{ + Meta: Meta{ + ClientToken: token, + Ui: ui, + }, + } + + var args []string + + args = []string{"-address", addr} + if code := c.Run(args); code == 0 { + t.Fatalf("expected failure due to no args") + } + + args = []string{"-address", addr, "testpath"} + if code := c.Run(args); code != 0 { + t.Fatalf("bad: %d\n\n%s", code, ui.ErrorWriter.String()) + } + + args = []string{"-address", addr, token, "test"} + if code := c.Run(args); code != 0 { + t.Fatalf("bad: %d\n\n%s", code, ui.ErrorWriter.String()) + } + + args = []string{"-address", addr, "invalidtoken", "test"} + if code := c.Run(args); code == 0 { + t.Fatalf("expected failure due to invalid token") + } +} diff --git a/http/handler.go b/http/handler.go index a9be257e35..0a6a1081b5 100644 --- a/http/handler.go +++ b/http/handler.go @@ -32,6 +32,8 @@ func Handler(core *vault.Core) http.Handler { mux.Handle("/v1/sys/generate-root/update", handleSysGenerateRootUpdate(core)) mux.Handle("/v1/sys/rekey/init", handleSysRekeyInit(core)) mux.Handle("/v1/sys/rekey/update", handleSysRekeyUpdate(core)) + mux.Handle("/v1/sys/capabilities", handleSysCapabilities(core)) + mux.Handle("/v1/sys/capabilities-self", handleSysCapabilities(core)) mux.Handle("/v1/sys/", handleLogical(core, true)) mux.Handle("/v1/", handleLogical(core, false)) diff --git a/http/sys_capabilities.go b/http/sys_capabilities.go new file mode 100644 index 0000000000..48f41c778c --- /dev/null +++ b/http/sys_capabilities.go @@ -0,0 +1,61 @@ +package http + +import ( + "net/http" + "strings" + + "github.com/hashicorp/errwrap" + "github.com/hashicorp/vault/logical" + "github.com/hashicorp/vault/vault" +) + +func handleSysCapabilities(core *vault.Core) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case "PUT": + case "POST": + default: + respondError(w, http.StatusMethodNotAllowed, nil) + return + } + + // Get the auth for the request so we can access the token directly + req := requestAuth(r, &logical.Request{}) + + // Parse the request if we can + var data capabilitiesRequest + if err := parseRequest(r, &data); err != nil { + respondError(w, http.StatusBadRequest, err) + return + } + + if strings.HasPrefix(r.URL.Path, "/v1/sys/capabilities-self") { + data.Token = req.ClientToken + } + + capabilities, err := core.Capabilities(data.Token, data.Path) + if err != nil { + if errwrap.ContainsType(err, new(vault.ErrUserInput)) { + respondError(w, http.StatusBadRequest, err) + return + } else { + respondError(w, http.StatusInternalServerError, err) + return + } + } + + respondOk(w, &capabilitiesResponse{ + Capabilities: capabilities, + }) + }) + +} + +type capabilitiesResponse struct { + Capabilities []string `json:"capabilities"` +} + +type capabilitiesRequest struct { + Token string `json:"token"` + Path string `json:"path"` +} diff --git a/http/sys_capabilities_test.go b/http/sys_capabilities_test.go new file mode 100644 index 0000000000..f192e2b0a6 --- /dev/null +++ b/http/sys_capabilities_test.go @@ -0,0 +1,82 @@ +package http + +import ( + "reflect" + "testing" + + "github.com/hashicorp/vault/vault" +) + +func TestSysCapabilities(t *testing.T) { + core, _, token := vault.TestCoreUnsealed(t) + ln, addr := TestServer(t, core) + defer ln.Close() + TestServerAuth(t, addr, token) + + // Send both token and path + resp := testHttpPost(t, token, addr+"/v1/sys/capabilities", map[string]interface{}{ + "token": token, + "path": "testpath", + }) + + var actual map[string][]string + testResponseStatus(t, resp, 200) + testResponseBody(t, resp, &actual) + + expected := map[string][]string{ + "capabilities": []string{"root"}, + } + if !reflect.DeepEqual(actual, expected) { + t.Fatalf("bad: got\n%#v\nexpected\n%#v\n", actual, expected) + } + + // Send only path to capabilities-self + resp = testHttpPost(t, token, addr+"/v1/sys/capabilities-self", map[string]interface{}{ + "path": "testpath", + }) + testResponseStatus(t, resp, 200) + testResponseBody(t, resp, &actual) + if !reflect.DeepEqual(actual, expected) { + t.Fatalf("bad: got\n%#v\nexpected\n%#v\n", actual, expected) + } + + // Testing for non-root tokens + + // Create a policy first + resp = testHttpPost(t, token, addr+"/v1/sys/policy/foo", map[string]interface{}{ + "rules": `path "testpath" {capabilities = ["read","sudo"]}`, + }) + testResponseStatus(t, resp, 204) + + // Create a token against the test policy + resp = testHttpPost(t, token, addr+"/v1/auth/token/create", map[string]interface{}{ + "policies": []string{"foo"}, + }) + + var tokenResp map[string]interface{} + testResponseStatus(t, resp, 200) + testResponseBody(t, resp, &tokenResp) + + // Check if desired policies are present in the token + auth := tokenResp["auth"].(map[string]interface{}) + actualPolicies := auth["policies"] + expectedPolicies := []interface{}{"default", "foo"} + if !reflect.DeepEqual(actualPolicies, expectedPolicies) { + t.Fatalf("bad: got\n%#v\nexpected\n%#v\n", actualPolicies, expectedPolicies) + } + + // Check the capabilities with the created non-root token + resp = testHttpPost(t, token, addr+"/v1/sys/capabilities", map[string]interface{}{ + "token": auth["client_token"], + "path": "testpath", + }) + testResponseStatus(t, resp, 200) + testResponseBody(t, resp, &actual) + + expected = map[string][]string{ + "capabilities": []string{"sudo", "read"}, + } + if !reflect.DeepEqual(actual, expected) { + t.Fatalf("bad: got\n%#v\nexpected\n%#v\n", actual, expected) + } +} diff --git a/vault/acl.go b/vault/acl.go index ac4922407b..ead38089d4 100644 --- a/vault/acl.go +++ b/vault/acl.go @@ -71,6 +71,56 @@ func NewACL(policies []*Policy) (*ACL, error) { return a, nil } +func (a *ACL) Capabilities(path string) (pathCapabilities []string) { + // Fast-path root + if a.root { + return []string{RootCapability} + } + + // Find an exact matching rule, look for glob if no match + var capabilities uint32 + raw, ok := a.exactRules.Get(path) + if ok { + capabilities = raw.(uint32) + goto CHECK + } + + // Find a glob rule, default deny if no match + _, raw, ok = a.globRules.LongestPrefix(path) + if !ok { + return []string{DenyCapability} + } else { + capabilities = raw.(uint32) + } + +CHECK: + if capabilities&SudoCapabilityInt > 0 { + pathCapabilities = append(pathCapabilities, SudoCapability) + } + if capabilities&ReadCapabilityInt > 0 { + pathCapabilities = append(pathCapabilities, ReadCapability) + } + if capabilities&ListCapabilityInt > 0 { + pathCapabilities = append(pathCapabilities, ListCapability) + } + if capabilities&UpdateCapabilityInt > 0 { + pathCapabilities = append(pathCapabilities, UpdateCapability) + } + if capabilities&DeleteCapabilityInt > 0 { + pathCapabilities = append(pathCapabilities, DeleteCapability) + } + if capabilities&CreateCapabilityInt > 0 { + pathCapabilities = append(pathCapabilities, CreateCapability) + } + + // If "deny" is explicitly set or if the path has no capabilities at all, + // set the path capabilities to "deny" + if capabilities&DenyCapabilityInt > 0 || len(pathCapabilities) == 0 { + pathCapabilities = []string{DenyCapability} + } + return +} + // AllowOperation is used to check if the given operation is permitted. The // first bool indicates if an op is allowed, the second whether sudo priviliges // exist for that op and path. diff --git a/vault/acl_test.go b/vault/acl_test.go index 41df6a2071..ac1ab4f7f9 100644 --- a/vault/acl_test.go +++ b/vault/acl_test.go @@ -1,11 +1,56 @@ package vault import ( + "reflect" "testing" "github.com/hashicorp/vault/logical" ) +func TestACL_Capabilities(t *testing.T) { + // Create the root policy ACL + policy := []*Policy{&Policy{Name: "root"}} + acl, err := NewACL(policy) + if err != nil { + t.Fatalf("err: %v", err) + } + + actual := acl.Capabilities("any/path") + expected := []string{"root"} + if !reflect.DeepEqual(actual, expected) { + t.Fatalf("bad: got\n%#v\nexpected\n%#v\n", actual, expected) + } + + policies, err := Parse(aclPolicy) + if err != nil { + t.Fatalf("err: %v", err) + } + + acl, err = NewACL([]*Policy{policies}) + if err != nil { + t.Fatalf("err: %v", err) + } + + actual = acl.Capabilities("dev") + expected = []string{"deny"} + if !reflect.DeepEqual(actual, expected) { + t.Fatalf("bad: path:%s\ngot\n%#v\nexpected\n%#v\n", "deny", actual, expected) + } + + actual = acl.Capabilities("dev/") + expected = []string{"sudo", "read", "list", "update", "delete", "create"} + if !reflect.DeepEqual(actual, expected) { + t.Fatalf("bad: path:%s\ngot\n%#v\nexpected\n%#v\n", "dev/", actual, expected) + } + + actual = acl.Capabilities("stage/aws/test") + expected = []string{"sudo", "read", "list", "update"} + if !reflect.DeepEqual(actual, expected) { + t.Fatalf("bad: path:%s\ngot\n%#v\nexpected\n%#v\n", "stage/aws/test", actual, expected) + } + +} + func TestACL_Root(t *testing.T) { // Create the root policy ACL policy := []*Policy{&Policy{Name: "root"}} diff --git a/vault/capabilities.go b/vault/capabilities.go new file mode 100644 index 0000000000..fb9c1ad934 --- /dev/null +++ b/vault/capabilities.go @@ -0,0 +1,62 @@ +package vault + +// Struct to identify user input errors. +// This is helpful in responding the appropriate status codes to clients +// from the HTTP endpoints. +type ErrUserInput struct { + Message string +} + +// Implementing error interface +func (e *ErrUserInput) Error() string { + return e.Message +} + +// Capabilities is used to fetch the capabilities of the given token on the given path +func (c *Core) Capabilities(token, path string) ([]string, error) { + if path == "" { + return nil, &ErrUserInput{ + Message: "missing path", + } + } + + if token == "" { + return nil, &ErrUserInput{ + Message: "missing token", + } + } + + te, err := c.tokenStore.Lookup(token) + if err != nil { + return nil, err + } + if te == nil { + return nil, &ErrUserInput{ + Message: "invalid token", + } + } + + if te.Policies == nil { + return []string{DenyCapability}, nil + } + + var policies []*Policy + for _, tePolicy := range te.Policies { + policy, err := c.policyStore.GetPolicy(tePolicy) + if err != nil { + return nil, err + } + policies = append(policies, policy) + } + + if len(policies) == 0 { + return []string{DenyCapability}, nil + } + + acl, err := NewACL(policies) + if err != nil { + return nil, err + } + + return acl.Capabilities(path), nil +} diff --git a/vault/capabilities_test.go b/vault/capabilities_test.go new file mode 100644 index 0000000000..8367dc90bd --- /dev/null +++ b/vault/capabilities_test.go @@ -0,0 +1,45 @@ +package vault + +import ( + "reflect" + "testing" +) + +func TestCapabilities_Basic(t *testing.T) { + c, _, token := TestCoreUnsealed(t) + + actual, err := c.Capabilities(token, "path") + if err != nil { + t.Fatalf("err: %s", err) + } + expected := []string{"root"} + if !reflect.DeepEqual(actual, expected) { + t.Fatalf("bad: got\n%#v\nexpected\n%#v\n", actual, expected) + } + + // Create a policy + policy, _ := Parse(aclPolicy) + err = c.policyStore.SetPolicy(policy) + if err != nil { + t.Fatalf("err: %v", err) + } + + // Create a token for the policy + ent := &TokenEntry{ + ID: "capabilitiestoken", + Path: "testpath", + Policies: []string{"dev"}, + } + if err := c.tokenStore.create(ent); err != nil { + t.Fatalf("err: %v", err) + } + + actual, err = c.Capabilities("capabilitiestoken", "foo/bar") + if err != nil { + t.Fatalf("err: %s", err) + } + expected = []string{"sudo", "read", "create"} + if !reflect.DeepEqual(actual, expected) { + t.Fatalf("bad: got\n%#v\nexpected\n%#v\n", actual, expected) + } +} diff --git a/vault/policy.go b/vault/policy.go index 6f856e7b72..7e459f59a9 100644 --- a/vault/policy.go +++ b/vault/policy.go @@ -15,6 +15,7 @@ const ( DeleteCapability = "delete" ListCapability = "list" SudoCapability = "sudo" + RootCapability = "root" // Backwards compatibility OldDenyPathPolicy = "deny" diff --git a/website/source/docs/http/sys-capabilities-self.html.md b/website/source/docs/http/sys-capabilities-self.html.md new file mode 100644 index 0000000000..f4fdbffba0 --- /dev/null +++ b/website/source/docs/http/sys-capabilities-self.html.md @@ -0,0 +1,44 @@ +--- +layout: "http" +page_title: "HTTP API: /sys/capabilities-self" +sidebar_current: "docs-http-auth-capabilities-self" +description: |- + The `/sys/capabilities-self` endpoint is used to fetch the capabilities of client token on a given path. +--- + +# /sys/capabilities-self + +## POST + +