Merge pull request #1171 from hashicorp/capabilities-endpoint

Capabilities endpoint
This commit is contained in:
Vishal Nayak 2016-03-08 13:12:09 -05:00
commit 3ad525fb7a
15 changed files with 632 additions and 0 deletions

48
api/sys_capabilities.go Normal file
View file

@ -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"`
}

View file

@ -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()

86
command/capabilities.go Normal file
View file

@ -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)
}

View file

@ -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")
}
}

View file

@ -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))

61
http/sys_capabilities.go Normal file
View file

@ -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"`
}

View file

@ -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)
}
}

View file

@ -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.

View file

@ -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"}}

62
vault/capabilities.go Normal file
View file

@ -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
}

View file

@ -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)
}
}

View file

@ -15,6 +15,7 @@ const (
DeleteCapability = "delete"
ListCapability = "list"
SudoCapability = "sudo"
RootCapability = "root"
// Backwards compatibility
OldDenyPathPolicy = "deny"

View file

@ -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
<dl>
<dt>Description</dt>
<dd>
Returns the capabilities of client token on the given path.
Client token is the Vault token with which this API call is made.
</dd>
<dt>Method</dt>
<dd>POST</dd>
<dt>Parameters</dt>
<dd>
<ul>
<li>
<span class="param">path</span>
<span class="param-flags">required</span>
Path on which the client token's capabilities will be checked.
</li>
</ul>
</dd>
<dt>Returns</dt>
<dd>
```javascript
{
"capabilities": ["read", "list"]
}
```
</dd>
</dl>

View file

@ -0,0 +1,48 @@
---
layout: "http"
page_title: "HTTP API: /sys/capabilities"
sidebar_current: "docs-http-auth-capabilities"
description: |-
The `/sys/capabilities` endpoint is used to fetch the capabilities of a token on a given path.
---
# /sys/capabilities
## POST
<dl>
<dt>Description</dt>
<dd>
Returns the capabilities of the token on the given path.
</dd>
<dt>Method</dt>
<dd>POST</dd>
<dt>Parameters</dt>
<dd>
<ul>
<li>
<span class="param">token</span>
<span class="param-flags">required</span>
Token for which capabilities are being queried.
</li>
<li>
<span class="param">path</span>
<span class="param-flags">required</span>
Path on which the token's capabilities will be checked.
</li>
</ul>
</dd>
<dt>Returns</dt>
<dd>
```javascript
{
"capabilities": ["read", "list"]
}
```
</dd>
</dl>

View file

@ -69,6 +69,14 @@
<li<%= sidebar_current("docs-http-auth-policy") %>>
<a href="/docs/http/sys-policy.html">/sys/policy</a>
</li>
<li<%= sidebar_current("docs-http-auth-capabilities") %>>
<a href="/docs/http/sys-capabilities.html">/sys/capabilities</a>
</li>
<li<%= sidebar_current("docs-http-auth-capabilities-self") %>>
<a href="/docs/http/sys-capabilities-self.html">/sys/capabilities-self</a>
</li>
</ul>
</li>