diff --git a/acl/acl.go b/acl/acl.go new file mode 100644 index 000000000..442837340 --- /dev/null +++ b/acl/acl.go @@ -0,0 +1,216 @@ +package acl + +import ( + "github.com/armon/go-radix" +) + +var ( + // allowAll is a singleton policy which allows all + // non-management actions + allowAll ACL + + // denyAll is a singleton policy which denies all actions + denyAll ACL + + // manageAll is a singleton policy which allows all + // actions, including management + manageAll ACL +) + +func init() { + // Setup the singletons + allowAll = &StaticACL{ + allowManage: false, + defaultAllow: true, + } + denyAll = &StaticACL{ + allowManage: false, + defaultAllow: false, + } + manageAll = &StaticACL{ + allowManage: true, + defaultAllow: true, + } +} + +// ACL is the interface for policy enforcement. +type ACL interface { + // KeyRead checks for permission to read a given key + KeyRead(string) bool + + // KeyWrite checks for permission to write a given key + KeyWrite(string) bool + + // KeyWritePrefix checks for permission to write to an + // entire key prefix. This means there must be no sub-policies + // that deny a write. + KeyWritePrefix(string) bool + + // ACLList checks for permission to list all the ACLs + ACLList() bool + + // ACLModify checks for permission to manipulate ACLs + ACLModify() bool +} + +// StaticACL is used to implement a base ACL policy. It either +// allows or denies all requests. This can be used as a parent +// ACL to act in a blacklist or whitelist mode. +type StaticACL struct { + allowManage bool + defaultAllow bool +} + +func (s *StaticACL) KeyRead(string) bool { + return s.defaultAllow +} + +func (s *StaticACL) KeyWrite(string) bool { + return s.defaultAllow +} + +func (s *StaticACL) KeyWritePrefix(string) bool { + return s.defaultAllow +} + +func (s *StaticACL) ACLList() bool { + return s.allowManage +} + +func (s *StaticACL) ACLModify() bool { + return s.allowManage +} + +// AllowAll returns an ACL rule that allows all operations +func AllowAll() ACL { + return allowAll +} + +// DenyAll returns an ACL rule that denies all operations +func DenyAll() ACL { + return denyAll +} + +// ManageAll returns an ACL rule that can manage all resources +func ManageAll() ACL { + return manageAll +} + +// RootACL returns a possible ACL if the ID matches a root policy +func RootACL(id string) ACL { + switch id { + case "allow": + return allowAll + case "deny": + return denyAll + case "manage": + return manageAll + default: + return nil + } +} + +// PolicyACL is used to wrap a set of ACL policies to provide +// the ACL interface. +type PolicyACL struct { + // parent is used to resolve policy if we have + // no matching rule. + parent ACL + + // keyRules contains the key policies + keyRules *radix.Tree +} + +// New is used to construct a policy based ACL from a set of policies +// and a parent policy to resolve missing cases. +func New(parent ACL, policy *Policy) (*PolicyACL, error) { + p := &PolicyACL{ + parent: parent, + keyRules: radix.New(), + } + + // Load the key policy + for _, kp := range policy.Keys { + p.keyRules.Insert(kp.Prefix, kp.Policy) + } + return p, nil +} + +// KeyRead returns if a key is allowed to be read +func (p *PolicyACL) KeyRead(key string) bool { + // Look for a matching rule + _, rule, ok := p.keyRules.LongestPrefix(key) + if ok { + switch rule.(string) { + case KeyPolicyRead: + return true + case KeyPolicyWrite: + return true + default: + return false + } + } + + // No matching rule, use the parent. + return p.parent.KeyRead(key) +} + +// KeyWrite returns if a key is allowed to be written +func (p *PolicyACL) KeyWrite(key string) bool { + // Look for a matching rule + _, rule, ok := p.keyRules.LongestPrefix(key) + if ok { + switch rule.(string) { + case KeyPolicyWrite: + return true + default: + return false + } + } + + // No matching rule, use the parent. + return p.parent.KeyWrite(key) +} + +// KeyWritePrefix returns if a prefix is allowed to be written +func (p *PolicyACL) KeyWritePrefix(prefix string) bool { + // Look for a matching rule that denies + _, rule, ok := p.keyRules.LongestPrefix(prefix) + if ok && rule.(string) != KeyPolicyWrite { + return false + } + + // Look if any of our children have a deny policy + deny := false + p.keyRules.WalkPrefix(prefix, func(path string, rule interface{}) bool { + // We have a rule to prevent a write in a sub-directory! + if rule.(string) != KeyPolicyWrite { + deny = true + return true + } + return false + }) + + // Deny the write if any sub-rules may be violated + if deny { + return false + } + + // If we had a matching rule, done + if ok { + return true + } + + // No matching rule, use the parent. + return p.parent.KeyWritePrefix(prefix) +} + +// ACLList checks if listing of ACLs is allowed +func (p *PolicyACL) ACLList() bool { + return p.parent.ACLList() +} + +// ACLModify checks if modification of ACLs is allowed +func (p *PolicyACL) ACLModify() bool { + return p.parent.ACLModify() +} diff --git a/acl/acl_test.go b/acl/acl_test.go new file mode 100644 index 000000000..9be0388db --- /dev/null +++ b/acl/acl_test.go @@ -0,0 +1,197 @@ +package acl + +import ( + "testing" +) + +func TestRootACL(t *testing.T) { + if RootACL("allow") != AllowAll() { + t.Fatalf("Bad root") + } + if RootACL("deny") != DenyAll() { + t.Fatalf("Bad root") + } + if RootACL("manage") != ManageAll() { + t.Fatalf("Bad root") + } + if RootACL("foo") != nil { + t.Fatalf("bad root") + } +} + +func TestStaticACL(t *testing.T) { + all := AllowAll() + if _, ok := all.(*StaticACL); !ok { + t.Fatalf("expected static") + } + + none := DenyAll() + if _, ok := none.(*StaticACL); !ok { + t.Fatalf("expected static") + } + + manage := ManageAll() + if _, ok := none.(*StaticACL); !ok { + t.Fatalf("expected static") + } + + if !all.KeyRead("foobar") { + t.Fatalf("should allow") + } + if !all.KeyWrite("foobar") { + t.Fatalf("should allow") + } + if all.ACLList() { + t.Fatalf("should not allow") + } + if all.ACLModify() { + t.Fatalf("should not allow") + } + + if none.KeyRead("foobar") { + t.Fatalf("should not allow") + } + if none.KeyWrite("foobar") { + t.Fatalf("should not allow") + } + if none.ACLList() { + t.Fatalf("should not noneow") + } + if none.ACLModify() { + t.Fatalf("should not noneow") + } + + if !manage.KeyRead("foobar") { + t.Fatalf("should allow") + } + if !manage.KeyWrite("foobar") { + t.Fatalf("should allow") + } + if !manage.ACLList() { + t.Fatalf("should allow") + } + if !manage.ACLModify() { + t.Fatalf("should allow") + } +} + +func TestPolicyACL(t *testing.T) { + all := AllowAll() + policy := &Policy{ + Keys: []*KeyPolicy{ + &KeyPolicy{ + Prefix: "foo/", + Policy: KeyPolicyWrite, + }, + &KeyPolicy{ + Prefix: "foo/priv/", + Policy: KeyPolicyDeny, + }, + &KeyPolicy{ + Prefix: "bar/", + Policy: KeyPolicyDeny, + }, + &KeyPolicy{ + Prefix: "zip/", + Policy: KeyPolicyRead, + }, + }, + } + acl, err := New(all, policy) + if err != nil { + t.Fatalf("err: %v", err) + } + + type tcase struct { + inp string + read bool + write bool + writePrefix bool + } + cases := []tcase{ + {"other", true, true, true}, + {"foo/test", true, true, true}, + {"foo/priv/test", false, false, false}, + {"bar/any", false, false, false}, + {"zip/test", true, false, false}, + {"foo/", true, true, false}, + {"", true, true, false}, + } + for _, c := range cases { + if c.read != acl.KeyRead(c.inp) { + t.Fatalf("Read fail: %#v", c) + } + if c.write != acl.KeyWrite(c.inp) { + t.Fatalf("Write fail: %#v", c) + } + if c.writePrefix != acl.KeyWritePrefix(c.inp) { + t.Fatalf("Write prefix fail: %#v", c) + } + } +} + +func TestPolicyACL_Parent(t *testing.T) { + deny := DenyAll() + policyRoot := &Policy{ + Keys: []*KeyPolicy{ + &KeyPolicy{ + Prefix: "foo/", + Policy: KeyPolicyWrite, + }, + &KeyPolicy{ + Prefix: "bar/", + Policy: KeyPolicyRead, + }, + }, + } + root, err := New(deny, policyRoot) + if err != nil { + t.Fatalf("err: %v", err) + } + + policy := &Policy{ + Keys: []*KeyPolicy{ + &KeyPolicy{ + Prefix: "foo/priv/", + Policy: KeyPolicyRead, + }, + &KeyPolicy{ + Prefix: "bar/", + Policy: KeyPolicyDeny, + }, + &KeyPolicy{ + Prefix: "zip/", + Policy: KeyPolicyRead, + }, + }, + } + acl, err := New(root, policy) + if err != nil { + t.Fatalf("err: %v", err) + } + + type tcase struct { + inp string + read bool + write bool + writePrefix bool + } + cases := []tcase{ + {"other", false, false, false}, + {"foo/test", true, true, true}, + {"foo/priv/test", true, false, false}, + {"bar/any", false, false, false}, + {"zip/test", true, false, false}, + } + for _, c := range cases { + if c.read != acl.KeyRead(c.inp) { + t.Fatalf("Read fail: %#v", c) + } + if c.write != acl.KeyWrite(c.inp) { + t.Fatalf("Write fail: %#v", c) + } + if c.writePrefix != acl.KeyWritePrefix(c.inp) { + t.Fatalf("Write prefix fail: %#v", c) + } + } +} diff --git a/acl/cache.go b/acl/cache.go new file mode 100644 index 000000000..6e7295562 --- /dev/null +++ b/acl/cache.go @@ -0,0 +1,164 @@ +package acl + +import ( + "crypto/md5" + "fmt" + + "github.com/hashicorp/golang-lru" +) + +// FaultFunc is a function used to fault in the parent, +// rules for an ACL given it's ID +type FaultFunc func(id string) (string, string, error) + +// aclEntry allows us to store the ACL with it's policy ID +type aclEntry struct { + ACL ACL + Parent string + RuleID string +} + +// Cache is used to implement policy and ACL caching +type Cache struct { + faultfn FaultFunc + aclCache *lru.Cache // Cache id -> acl + policyCache *lru.Cache // Cache policy -> acl + ruleCache *lru.Cache // Cache rules -> policy +} + +// NewCache contructs a new policy and ACL cache of a given size +func NewCache(size int, faultfn FaultFunc) (*Cache, error) { + if size <= 0 { + return nil, fmt.Errorf("Must provide positive cache size") + } + rc, _ := lru.New(size) + pc, _ := lru.New(size) + ac, _ := lru.New(size) + c := &Cache{ + faultfn: faultfn, + aclCache: ac, + policyCache: pc, + ruleCache: rc, + } + return c, nil +} + +// GetPolicy is used to get a potentially cached policy set. +// If not cached, it will be parsed, and then cached. +func (c *Cache) GetPolicy(rules string) (*Policy, error) { + return c.getPolicy(c.ruleID(rules), rules) +} + +// getPolicy is an internal method to get a cached policy, +// but it assumes a pre-computed ID +func (c *Cache) getPolicy(id, rules string) (*Policy, error) { + raw, ok := c.ruleCache.Get(id) + if ok { + return raw.(*Policy), nil + } + policy, err := Parse(rules) + if err != nil { + return nil, err + } + policy.ID = id + c.ruleCache.Add(id, policy) + return policy, nil + +} + +// ruleID is used to generate an ID for a rule +func (c *Cache) ruleID(rules string) string { + return fmt.Sprintf("%x", md5.Sum([]byte(rules))) +} + +// policyID returns the cache ID for a policy +func (c *Cache) policyID(parent, ruleID string) string { + return parent + ":" + ruleID +} + +// GetACLPolicy is used to get the potentially cached ACL +// policy. If not cached, it will be generated and then cached. +func (c *Cache) GetACLPolicy(id string) (string, *Policy, error) { + // Check for a cached acl + if raw, ok := c.aclCache.Get(id); ok { + cached := raw.(aclEntry) + if raw, ok := c.ruleCache.Get(cached.RuleID); ok { + return cached.Parent, raw.(*Policy), nil + } + } + + // Fault in the rules + parent, rules, err := c.faultfn(id) + if err != nil { + return "", nil, err + } + + // Get cached + policy, err := c.GetPolicy(rules) + return parent, policy, err +} + +// GetACL is used to get a potentially cached ACL policy. +// If not cached, it will be generated and then cached. +func (c *Cache) GetACL(id string) (ACL, error) { + // Look for the ACL directly + raw, ok := c.aclCache.Get(id) + if ok { + return raw.(aclEntry).ACL, nil + } + + // Get the rules + parentID, rules, err := c.faultfn(id) + if err != nil { + return nil, err + } + ruleID := c.ruleID(rules) + + // Check for a compiled ACL + policyID := c.policyID(parentID, ruleID) + var compiled ACL + if raw, ok := c.policyCache.Get(policyID); ok { + compiled = raw.(ACL) + } else { + // Get the policy + policy, err := c.getPolicy(ruleID, rules) + if err != nil { + return nil, err + } + + // Get the parent ACL + parent := RootACL(parentID) + if parent == nil { + parent, err = c.GetACL(parentID) + if err != nil { + return nil, err + } + } + + // Compile the ACL + acl, err := New(parent, policy) + if err != nil { + return nil, err + } + + // Cache the compiled ACL + c.policyCache.Add(policyID, acl) + compiled = acl + } + + // Cache and return the ACL + c.aclCache.Add(id, aclEntry{compiled, parentID, ruleID}) + return compiled, nil +} + +// ClearACL is used to clear the ACL cache if any +func (c *Cache) ClearACL(id string) { + c.aclCache.Remove(id) +} + +// Purge is used to clear all the ACL caches. The +// rule and policy caches are not purged, since they +// are content-hashed anyways. +func (c *Cache) Purge() { + c.aclCache.Purge() +} diff --git a/acl/cache_test.go b/acl/cache_test.go new file mode 100644 index 000000000..f880bcaf4 --- /dev/null +++ b/acl/cache_test.go @@ -0,0 +1,298 @@ +package acl + +import ( + "testing" +) + +func TestCache_GetPolicy(t *testing.T) { + c, err := NewCache(1, nil) + if err != nil { + t.Fatalf("err: %v", err) + } + + p, err := c.GetPolicy("") + if err != nil { + t.Fatalf("err: %v", err) + } + + // Should get the same policy + p1, err := c.GetPolicy("") + if err != nil { + t.Fatalf("err: %v", err) + } + if p != p1 { + t.Fatalf("should be cached") + } + + // Cache a new policy + _, err = c.GetPolicy(testSimplePolicy) + if err != nil { + t.Fatalf("err: %v", err) + } + + // Test invalidation of p + p3, err := c.GetPolicy("") + if err != nil { + t.Fatalf("err: %v", err) + } + if p == p3 { + t.Fatalf("should be not cached") + } +} + +func TestCache_GetACL(t *testing.T) { + policies := map[string]string{ + "foo": testSimplePolicy, + "bar": testSimplePolicy2, + } + faultfn := func(id string) (string, string, error) { + return "deny", policies[id], nil + } + + c, err := NewCache(1, faultfn) + if err != nil { + t.Fatalf("err: %v", err) + } + + acl, err := c.GetACL("foo") + if err != nil { + t.Fatalf("err: %v", err) + } + + if acl.KeyRead("bar/test") { + t.Fatalf("should deny") + } + if !acl.KeyRead("foo/test") { + t.Fatalf("should allow") + } + + acl2, err := c.GetACL("foo") + if err != nil { + t.Fatalf("err: %v", err) + } + + if acl != acl2 { + t.Fatalf("should be cached") + } + + // Invalidate cache + _, err = c.GetACL("bar") + if err != nil { + t.Fatalf("err: %v", err) + } + + acl3, err := c.GetACL("foo") + if err != nil { + t.Fatalf("err: %v", err) + } + + if acl == acl3 { + t.Fatalf("should not be cached") + } +} + +func TestCache_ClearACL(t *testing.T) { + policies := map[string]string{ + "foo": testSimplePolicy, + "bar": testSimplePolicy, + } + faultfn := func(id string) (string, string, error) { + return "deny", policies[id], nil + } + + c, err := NewCache(1, faultfn) + if err != nil { + t.Fatalf("err: %v", err) + } + + acl, err := c.GetACL("foo") + if err != nil { + t.Fatalf("err: %v", err) + } + + // Nuke the cache + c.ClearACL("foo") + + // Clear the policy cache + c.policyCache.Purge() + + acl2, err := c.GetACL("foo") + if err != nil { + t.Fatalf("err: %v", err) + } + + if acl == acl2 { + t.Fatalf("should not be cached") + } +} + +func TestCache_Purge(t *testing.T) { + policies := map[string]string{ + "foo": testSimplePolicy, + "bar": testSimplePolicy, + } + faultfn := func(id string) (string, string, error) { + return "deny", policies[id], nil + } + + c, err := NewCache(1, faultfn) + if err != nil { + t.Fatalf("err: %v", err) + } + + acl, err := c.GetACL("foo") + if err != nil { + t.Fatalf("err: %v", err) + } + + // Nuke the cache + c.Purge() + c.policyCache.Purge() + + acl2, err := c.GetACL("foo") + if err != nil { + t.Fatalf("err: %v", err) + } + + if acl == acl2 { + t.Fatalf("should not be cached") + } +} + +func TestCache_GetACLPolicy(t *testing.T) { + policies := map[string]string{ + "foo": testSimplePolicy, + "bar": testSimplePolicy, + } + faultfn := func(id string) (string, string, error) { + return "deny", policies[id], nil + } + c, err := NewCache(1, faultfn) + if err != nil { + t.Fatalf("err: %v", err) + } + + p, err := c.GetPolicy(testSimplePolicy) + if err != nil { + t.Fatalf("err: %v", err) + } + + _, err = c.GetACL("foo") + if err != nil { + t.Fatalf("err: %v", err) + } + + parent, p2, err := c.GetACLPolicy("foo") + if err != nil { + t.Fatalf("err: %v", err) + } + if parent != "deny" { + t.Fatalf("bad: %v", parent) + } + + if p2 != p { + t.Fatalf("expected cached policy") + } + + parent, p3, err := c.GetACLPolicy("bar") + if err != nil { + t.Fatalf("err: %v", err) + } + if parent != "deny" { + t.Fatalf("bad: %v", parent) + } + + if p3 != p { + t.Fatalf("expected cached policy") + } +} + +func TestCache_GetACL_Parent(t *testing.T) { + faultfn := func(id string) (string, string, error) { + switch id { + case "foo": + // Foo inherits from bar + return "bar", testSimplePolicy, nil + case "bar": + return "deny", testSimplePolicy2, nil + } + t.Fatalf("bad case") + return "", "", nil + } + + c, err := NewCache(1, faultfn) + if err != nil { + t.Fatalf("err: %v", err) + } + + acl, err := c.GetACL("foo") + if err != nil { + t.Fatalf("err: %v", err) + } + + if !acl.KeyRead("bar/test") { + t.Fatalf("should allow") + } + if !acl.KeyRead("foo/test") { + t.Fatalf("should allow") + } +} + +func TestCache_GetACL_ParentCache(t *testing.T) { + // Same rules, different parent + faultfn := func(id string) (string, string, error) { + switch id { + case "foo": + return "allow", testSimplePolicy, nil + case "bar": + return "deny", testSimplePolicy, nil + } + t.Fatalf("bad case") + return "", "", nil + } + + c, err := NewCache(16, faultfn) + if err != nil { + t.Fatalf("err: %v", err) + } + + acl, err := c.GetACL("foo") + if err != nil { + t.Fatalf("err: %v", err) + } + + if !acl.KeyRead("bar/test") { + t.Fatalf("should allow") + } + if !acl.KeyRead("foo/test") { + t.Fatalf("should allow") + } + + acl2, err := c.GetACL("bar") + if err != nil { + t.Fatalf("err: %v", err) + } + + if acl == acl2 { + t.Fatalf("should not match") + } + + if acl2.KeyRead("bar/test") { + t.Fatalf("should not allow") + } + if !acl2.KeyRead("foo/test") { + t.Fatalf("should allow") + } +} + +var testSimplePolicy = ` +key "foo/" { + policy = "read" +} +` + +var testSimplePolicy2 = ` +key "bar/" { + policy = "read" +} +` diff --git a/acl/policy.go b/acl/policy.go new file mode 100644 index 000000000..014ef51ac --- /dev/null +++ b/acl/policy.go @@ -0,0 +1,57 @@ +package acl + +import ( + "fmt" + "github.com/hashicorp/hcl" +) + +const ( + KeyPolicyDeny = "deny" + KeyPolicyRead = "read" + KeyPolicyWrite = "write" +) + +// Policy is used to represent the policy specified by +// an ACL configuration. +type Policy struct { + ID string `hcl:"-"` + Keys []*KeyPolicy `hcl:"key,expand"` +} + +// KeyPolicy represents a policy for a key +type KeyPolicy struct { + Prefix string `hcl:",key"` + Policy string +} + +func (k *KeyPolicy) GoString() string { + return fmt.Sprintf("%#v", *k) +} + +// Parse is used to parse the specified ACL rules into an +// intermediary set of policies, before being compiled into +// the ACL +func Parse(rules string) (*Policy, error) { + // Decode the rules + p := &Policy{} + if rules == "" { + // Hot path for empty rules + return p, nil + } + + if err := hcl.Decode(p, rules); err != nil { + return nil, fmt.Errorf("Failed to parse ACL rules: %v", err) + } + + // Validate the key policy + for _, kp := range p.Keys { + switch kp.Policy { + case KeyPolicyDeny: + case KeyPolicyRead: + case KeyPolicyWrite: + default: + return nil, fmt.Errorf("Invalid key policy: %#v", kp) + } + } + return p, nil +} diff --git a/acl/policy_test.go b/acl/policy_test.go new file mode 100644 index 000000000..0fc75e0fa --- /dev/null +++ b/acl/policy_test.go @@ -0,0 +1,100 @@ +package acl + +import ( + "reflect" + "testing" +) + +func TestParse(t *testing.T) { + inp := ` +key "" { + policy = "read" +} +key "foo/" { + policy = "write" +} +key "foo/bar/" { + policy = "read" +} +key "foo/bar/baz" { + policy = "deny" +} + ` + exp := &Policy{ + Keys: []*KeyPolicy{ + &KeyPolicy{ + Prefix: "", + Policy: KeyPolicyRead, + }, + &KeyPolicy{ + Prefix: "foo/", + Policy: KeyPolicyWrite, + }, + &KeyPolicy{ + Prefix: "foo/bar/", + Policy: KeyPolicyRead, + }, + &KeyPolicy{ + Prefix: "foo/bar/baz", + Policy: KeyPolicyDeny, + }, + }, + } + + out, err := Parse(inp) + if err != nil { + t.Fatalf("err: %v", err) + } + + if !reflect.DeepEqual(out, exp) { + t.Fatalf("bad: %#v %#v", out, exp) + } +} + +func TestParse_JSON(t *testing.T) { + inp := `{ + "key": { + "": { + "policy": "read" + }, + "foo/": { + "policy": "write" + }, + "foo/bar/": { + "policy": "read" + }, + "foo/bar/baz": { + "policy": "deny" + } + } +}` + exp := &Policy{ + Keys: []*KeyPolicy{ + &KeyPolicy{ + Prefix: "", + Policy: KeyPolicyRead, + }, + &KeyPolicy{ + Prefix: "foo/", + Policy: KeyPolicyWrite, + }, + &KeyPolicy{ + Prefix: "foo/bar/", + Policy: KeyPolicyRead, + }, + &KeyPolicy{ + Prefix: "foo/bar/baz", + Policy: KeyPolicyDeny, + }, + }, + } + + out, err := Parse(inp) + if err != nil { + t.Fatalf("err: %v", err) + } + + if !reflect.DeepEqual(out, exp) { + t.Fatalf("bad: %#v %#v", out, exp) + } +} diff --git a/command/agent/acl_endpoint.go b/command/agent/acl_endpoint.go new file mode 100644 index 000000000..52db96fec --- /dev/null +++ b/command/agent/acl_endpoint.go @@ -0,0 +1,190 @@ +package agent + +import ( + "fmt" + "github.com/hashicorp/consul/consul/structs" + "net/http" + "strings" +) + +// aclCreateResponse is used to wrap the ACL ID +type aclCreateResponse struct { + ID string +} + +// aclDisabled handles if ACL datacenter is not configured +func aclDisabled(resp http.ResponseWriter, req *http.Request) (interface{}, error) { + resp.WriteHeader(401) + resp.Write([]byte("ACL support disabled")) + return nil, nil +} + +func (s *HTTPServer) ACLDestroy(resp http.ResponseWriter, req *http.Request) (interface{}, error) { + args := structs.ACLRequest{ + Datacenter: s.agent.config.ACLDatacenter, + Op: structs.ACLDelete, + } + s.parseToken(req, &args.Token) + + // Pull out the acl id + args.ACL.ID = strings.TrimPrefix(req.URL.Path, "/v1/acl/destroy/") + if args.ACL.ID == "" { + resp.WriteHeader(400) + resp.Write([]byte("Missing ACL")) + return nil, nil + } + + var out string + if err := s.agent.RPC("ACL.Apply", &args, &out); err != nil { + return nil, err + } + return true, nil +} + +func (s *HTTPServer) ACLCreate(resp http.ResponseWriter, req *http.Request) (interface{}, error) { + return s.aclSet(resp, req, false) +} + +func (s *HTTPServer) ACLUpdate(resp http.ResponseWriter, req *http.Request) (interface{}, error) { + return s.aclSet(resp, req, true) +} + +func (s *HTTPServer) aclSet(resp http.ResponseWriter, req *http.Request, update bool) (interface{}, error) { + // Mandate a PUT request + if req.Method != "PUT" { + resp.WriteHeader(405) + return nil, nil + } + + args := structs.ACLRequest{ + Datacenter: s.agent.config.ACLDatacenter, + Op: structs.ACLSet, + ACL: structs.ACL{ + Type: structs.ACLTypeClient, + }, + } + s.parseToken(req, &args.Token) + + // Handle optional request body + if req.ContentLength > 0 { + if err := decodeBody(req, &args.ACL, nil); err != nil { + resp.WriteHeader(400) + resp.Write([]byte(fmt.Sprintf("Request decode failed: %v", err))) + return nil, nil + } + } + + // Ensure there is no ID set for create + if !update && args.ACL.ID != "" { + resp.WriteHeader(400) + resp.Write([]byte(fmt.Sprintf("ACL ID cannot be set"))) + return nil, nil + } + + // Ensure there is an ID set for update + if update && args.ACL.ID == "" { + resp.WriteHeader(400) + resp.Write([]byte(fmt.Sprintf("ACL ID must be set"))) + return nil, nil + } + + // Create the acl, get the ID + var out string + if err := s.agent.RPC("ACL.Apply", &args, &out); err != nil { + return nil, err + } + + // Format the response as a JSON object + return aclCreateResponse{out}, nil +} + +func (s *HTTPServer) ACLClone(resp http.ResponseWriter, req *http.Request) (interface{}, error) { + args := structs.ACLSpecificRequest{ + Datacenter: s.agent.config.ACLDatacenter, + } + var dc string + if done := s.parse(resp, req, &dc, &args.QueryOptions); done { + return nil, nil + } + + // Pull out the acl id + args.ACL = strings.TrimPrefix(req.URL.Path, "/v1/acl/clone/") + if args.ACL == "" { + resp.WriteHeader(400) + resp.Write([]byte("Missing ACL")) + return nil, nil + } + + var out structs.IndexedACLs + defer setMeta(resp, &out.QueryMeta) + if err := s.agent.RPC("ACL.Get", &args, &out); err != nil { + return nil, err + } + + // Bail if the ACL is not found + if len(out.ACLs) == 0 { + resp.WriteHeader(404) + resp.Write([]byte(fmt.Sprintf("Target ACL not found"))) + return nil, nil + } + + // Create a new ACL + createArgs := structs.ACLRequest{ + Datacenter: args.Datacenter, + Op: structs.ACLSet, + ACL: *out.ACLs[0], + } + createArgs.ACL.ID = "" + createArgs.Token = args.Token + + // Create the acl, get the ID + var outID string + if err := s.agent.RPC("ACL.Apply", &createArgs, &outID); err != nil { + return nil, err + } + + // Format the response as a JSON object + return aclCreateResponse{outID}, nil +} + +func (s *HTTPServer) ACLGet(resp http.ResponseWriter, req *http.Request) (interface{}, error) { + args := structs.ACLSpecificRequest{ + Datacenter: s.agent.config.ACLDatacenter, + } + var dc string + if done := s.parse(resp, req, &dc, &args.QueryOptions); done { + return nil, nil + } + + // Pull out the acl id + args.ACL = strings.TrimPrefix(req.URL.Path, "/v1/acl/info/") + if args.ACL == "" { + resp.WriteHeader(400) + resp.Write([]byte("Missing ACL")) + return nil, nil + } + + var out structs.IndexedACLs + defer setMeta(resp, &out.QueryMeta) + if err := s.agent.RPC("ACL.Get", &args, &out); err != nil { + return nil, err + } + return out.ACLs, nil +} + +func (s *HTTPServer) ACLList(resp http.ResponseWriter, req *http.Request) (interface{}, error) { + args := structs.DCSpecificRequest{ + Datacenter: s.agent.config.ACLDatacenter, + } + var dc string + if done := s.parse(resp, req, &dc, &args.QueryOptions); done { + return nil, nil + } + + var out structs.IndexedACLs + defer setMeta(resp, &out.QueryMeta) + if err := s.agent.RPC("ACL.List", &args, &out); err != nil { + return nil, err + } + return out.ACLs, nil +} diff --git a/command/agent/acl_endpoint_test.go b/command/agent/acl_endpoint_test.go new file mode 100644 index 000000000..9db7971b4 --- /dev/null +++ b/command/agent/acl_endpoint_test.go @@ -0,0 +1,160 @@ +package agent + +import ( + "bytes" + "encoding/json" + "github.com/hashicorp/consul/consul/structs" + "net/http" + "net/http/httptest" + "testing" +) + +func makeTestACL(t *testing.T, srv *HTTPServer) string { + body := bytes.NewBuffer(nil) + enc := json.NewEncoder(body) + raw := map[string]interface{}{ + "Name": "User Token", + "Type": "client", + "Rules": "", + } + enc.Encode(raw) + + req, err := http.NewRequest("PUT", "/v1/acl/create", body) + if err != nil { + t.Fatalf("err: %v", err) + } + resp := httptest.NewRecorder() + obj, err := srv.ACLCreate(resp, req) + if err != nil { + t.Fatalf("err: %v", err) + } + aclResp := obj.(aclCreateResponse) + return aclResp.ID +} + +func TestACLUpdate(t *testing.T) { + httpTest(t, func(srv *HTTPServer) { + id := makeTestACL(t, srv) + + body := bytes.NewBuffer(nil) + enc := json.NewEncoder(body) + raw := map[string]interface{}{ + "ID": id, + "Name": "User Token 2", + "Type": "client", + "Rules": "", + } + enc.Encode(raw) + + req, err := http.NewRequest("PUT", "/v1/acl/update", body) + if err != nil { + t.Fatalf("err: %v", err) + } + resp := httptest.NewRecorder() + obj, err := srv.ACLUpdate(resp, req) + if err != nil { + t.Fatalf("err: %v", err) + } + aclResp := obj.(aclCreateResponse) + if aclResp.ID != id { + t.Fatalf("bad: %v", aclResp) + } + }) +} + +func TestACLDestroy(t *testing.T) { + httpTest(t, func(srv *HTTPServer) { + id := makeTestACL(t, srv) + req, err := http.NewRequest("PUT", "/v1/session/destroy/"+id, nil) + resp := httptest.NewRecorder() + obj, err := srv.ACLDestroy(resp, req) + if err != nil { + t.Fatalf("err: %v", err) + } + if resp := obj.(bool); !resp { + t.Fatalf("should work") + } + }) +} + +func TestACLClone(t *testing.T) { + httpTest(t, func(srv *HTTPServer) { + id := makeTestACL(t, srv) + + req, err := http.NewRequest("GET", + "/v1/acl/clone/"+id, nil) + resp := httptest.NewRecorder() + obj, err := srv.ACLClone(resp, req) + if err != nil { + t.Fatalf("err: %v", err) + } + aclResp, ok := obj.(aclCreateResponse) + if !ok { + t.Fatalf("should work: %#v %#v", obj, resp) + } + if aclResp.ID == id { + t.Fatalf("bad id") + } + + req, err = http.NewRequest("GET", + "/v1/acl/info/"+aclResp.ID, nil) + resp = httptest.NewRecorder() + obj, err = srv.ACLGet(resp, req) + if err != nil { + t.Fatalf("err: %v", err) + } + respObj, ok := obj.(structs.ACLs) + if !ok { + t.Fatalf("should work") + } + if len(respObj) != 1 { + t.Fatalf("bad: %v", respObj) + } + }) +} + +func TestACLGet(t *testing.T) { + httpTest(t, func(srv *HTTPServer) { + id := makeTestACL(t, srv) + + req, err := http.NewRequest("GET", + "/v1/acl/info/"+id, nil) + resp := httptest.NewRecorder() + obj, err := srv.ACLGet(resp, req) + if err != nil { + t.Fatalf("err: %v", err) + } + respObj, ok := obj.(structs.ACLs) + if !ok { + t.Fatalf("should work") + } + if len(respObj) != 1 { + t.Fatalf("bad: %v", respObj) + } + }) +} + +func TestACLList(t *testing.T) { + httpTest(t, func(srv *HTTPServer) { + var ids []string + for i := 0; i < 10; i++ { + ids = append(ids, makeTestACL(t, srv)) + } + + req, err := http.NewRequest("GET", "/v1/acl/list", nil) + resp := httptest.NewRecorder() + obj, err := srv.ACLList(resp, req) + if err != nil { + t.Fatalf("err: %v", err) + } + respObj, ok := obj.(structs.ACLs) + if !ok { + t.Fatalf("should work") + } + + // 10 + anonymous + if len(respObj) != 11 { + t.Fatalf("bad: %v", respObj) + } + }) +} diff --git a/command/agent/agent.go b/command/agent/agent.go index 0d8cecfdf..289637adc 100644 --- a/command/agent/agent.go +++ b/command/agent/agent.go @@ -181,6 +181,24 @@ func (a *Agent) consulConfig() *consul.Config { if a.config.Protocol > 0 { base.ProtocolVersion = uint8(a.config.Protocol) } + if a.config.ACLToken != "" { + base.ACLToken = a.config.ACLToken + } + if a.config.ACLMasterToken != "" { + base.ACLMasterToken = a.config.ACLMasterToken + } + if a.config.ACLDatacenter != "" { + base.ACLDatacenter = a.config.ACLDatacenter + } + if a.config.ACLTTLRaw != "" { + base.ACLTTL = a.config.ACLTTL + } + if a.config.ACLDefaultPolicy != "" { + base.ACLDefaultPolicy = a.config.ACLDefaultPolicy + } + if a.config.ACLDownPolicy != "" { + base.ACLDownPolicy = a.config.ACLDownPolicy + } // Format the build string revision := a.config.Revision diff --git a/command/agent/agent_test.go b/command/agent/agent_test.go index a9ca15694..d03596b38 100644 --- a/command/agent/agent_test.go +++ b/command/agent/agent_test.go @@ -30,6 +30,7 @@ func nextConfig() *Config { conf.Ports.SerfWan = 18300 + idx conf.Ports.Server = 18100 + idx conf.Server = true + conf.ACLDatacenter = "dc1" cons := consul.DefaultConfig() conf.ConsulConfig = cons diff --git a/command/agent/config.go b/command/agent/config.go index 9a6a043f8..87584e1a2 100644 --- a/command/agent/config.go +++ b/command/agent/config.go @@ -194,6 +194,41 @@ type Config struct { CheckUpdateInterval time.Duration `mapstructure:"-"` CheckUpdateIntervalRaw string `mapstructure:"check_update_interval" json:"-"` + // ACLToken is the default token used to make requests if a per-request + // token is not provided. If not configured the 'anonymous' token is used. + ACLToken string `mapstructure:"acl_token" json:"-"` + + // ACLMasterToken is used to bootstrap the ACL system. It should be specified + // on the servers in the ACLDatacenter. When the leader comes online, it ensures + // that the Master token is available. This provides the initial token. + ACLMasterToken string `mapstructure:"acl_master_token" json:"-"` + + // ACLDatacenter is the central datacenter that holds authoritative + // ACL records. This must be the same for the entire cluster. + // If this is not set, ACLs are not enabled. Off by default. + ACLDatacenter string `mapstructure:"acl_datacenter"` + + // ACLTTL is used to control the time-to-live of cached ACLs . This has + // a major impact on performance. By default, it is set to 30 seconds. + ACLTTL time.Duration `mapstructure:"-"` + ACLTTLRaw string `mapstructure:"acl_ttl"` + + // ACLDefaultPolicy is used to control the ACL interaction when + // there is no defined policy. This can be "allow" which means + // ACLs are used to black-list, or "deny" which means ACLs are + // white-lists. + ACLDefaultPolicy string `mapstructure:"acl_default_policy"` + + // ACLDownPolicy is used to control the ACL interaction when we cannot + // reach the ACLDatacenter and the token is not in the cache. + // There are two modes: + // * deny - Deny all requests + // * extend-cache - Ignore the cache expiration, and allow cached + // ACL's to be used to service requests. This + // is the default. If the ACL is not in the cache, + // this acts like deny. + ACLDownPolicy string `mapstructure:"acl_down_policy"` + // AEInterval controls the anti-entropy interval. This is how often // the agent attempts to reconcile it's local state with the server' // representation of our state. Defaults to every 60s. @@ -246,6 +281,9 @@ func DefaultConfig() *Config { Protocol: consul.ProtocolVersionMax, CheckUpdateInterval: 5 * time.Minute, AEInterval: time.Minute, + ACLTTL: 30 * time.Second, + ACLDownPolicy: "extend-cache", + ACLDefaultPolicy: "allow", } } @@ -341,6 +379,14 @@ func DecodeConfig(r io.Reader) (*Config, error) { result.CheckUpdateInterval = dur } + if raw := result.ACLTTLRaw; raw != "" { + dur, err := time.ParseDuration(raw) + if err != nil { + return nil, fmt.Errorf("ACL TTL invalid: %v", err) + } + result.ACLTTL = dur + } + return &result, nil } @@ -583,6 +629,25 @@ func MergeConfig(a, b *Config) *Config { if b.SyslogFacility != "" { result.SyslogFacility = b.SyslogFacility } + if b.ACLToken != "" { + result.ACLToken = b.ACLToken + } + if b.ACLMasterToken != "" { + result.ACLMasterToken = b.ACLMasterToken + } + if b.ACLDatacenter != "" { + result.ACLDatacenter = b.ACLDatacenter + } + if b.ACLTTLRaw != "" { + result.ACLTTL = b.ACLTTL + result.ACLTTLRaw = b.ACLTTLRaw + } + if b.ACLDownPolicy != "" { + result.ACLDownPolicy = b.ACLDownPolicy + } + if b.ACLDefaultPolicy != "" { + result.ACLDefaultPolicy = b.ACLDefaultPolicy + } // Copy the start join addresses result.StartJoin = make([]string, 0, len(a.StartJoin)+len(b.StartJoin)) diff --git a/command/agent/config_test.go b/command/agent/config_test.go index 0c6db15e1..9bc67c69c 100644 --- a/command/agent/config_test.go +++ b/command/agent/config_test.go @@ -356,6 +356,34 @@ func TestDecodeConfig(t *testing.T) { if config.CheckUpdateInterval != 10*time.Minute { t.Fatalf("bad: %#v", config) } + + // ACLs + input = `{"acl_token": "1234", "acl_datacenter": "dc2", + "acl_ttl": "60s", "acl_down_policy": "deny", + "acl_default_policy": "deny", "acl_master_token": "2345"}` + config, err = DecodeConfig(bytes.NewReader([]byte(input))) + if err != nil { + t.Fatalf("err: %s", err) + } + + if config.ACLToken != "1234" { + t.Fatalf("bad: %#v", config) + } + if config.ACLMasterToken != "2345" { + t.Fatalf("bad: %#v", config) + } + if config.ACLDatacenter != "dc2" { + t.Fatalf("bad: %#v", config) + } + if config.ACLTTL != 60*time.Second { + t.Fatalf("bad: %#v", config) + } + if config.ACLDownPolicy != "deny" { + t.Fatalf("bad: %#v", config) + } + if config.ACLDefaultPolicy != "deny" { + t.Fatalf("bad: %#v", config) + } } func TestDecodeConfig_Service(t *testing.T) { @@ -503,6 +531,13 @@ func TestMergeConfig(t *testing.T) { RejoinAfterLeave: true, CheckUpdateInterval: 8 * time.Minute, CheckUpdateIntervalRaw: "8m", + ACLToken: "1234", + ACLMasterToken: "2345", + ACLDatacenter: "dc2", + ACLTTL: 15 * time.Second, + ACLTTLRaw: "15s", + ACLDownPolicy: "deny", + ACLDefaultPolicy: "deny", } c := MergeConfig(a, b) diff --git a/command/agent/http.go b/command/agent/http.go index a254ecf19..43b8019e9 100644 --- a/command/agent/http.go +++ b/command/agent/http.go @@ -99,6 +99,22 @@ func (s *HTTPServer) registerHandlers(enableDebug bool) { s.mux.HandleFunc("/v1/session/node/", s.wrap(s.SessionsForNode)) s.mux.HandleFunc("/v1/session/list", s.wrap(s.SessionList)) + if s.agent.config.ACLDatacenter != "" { + s.mux.HandleFunc("/v1/acl/create", s.wrap(s.ACLCreate)) + s.mux.HandleFunc("/v1/acl/update", s.wrap(s.ACLUpdate)) + s.mux.HandleFunc("/v1/acl/destroy/", s.wrap(s.ACLDestroy)) + s.mux.HandleFunc("/v1/acl/info/", s.wrap(s.ACLGet)) + s.mux.HandleFunc("/v1/acl/clone/", s.wrap(s.ACLClone)) + s.mux.HandleFunc("/v1/acl/list", s.wrap(s.ACLList)) + } else { + s.mux.HandleFunc("/v1/acl/create", s.wrap(aclDisabled)) + s.mux.HandleFunc("/v1/acl/update", s.wrap(aclDisabled)) + s.mux.HandleFunc("/v1/acl/destroy/", s.wrap(aclDisabled)) + s.mux.HandleFunc("/v1/acl/info/", s.wrap(aclDisabled)) + s.mux.HandleFunc("/v1/acl/clone/", s.wrap(aclDisabled)) + s.mux.HandleFunc("/v1/acl/list", s.wrap(aclDisabled)) + } + if enableDebug { s.mux.HandleFunc("/debug/pprof/", pprof.Index) s.mux.HandleFunc("/debug/pprof/cmdline", pprof.Cmdline) @@ -273,10 +289,20 @@ func (s *HTTPServer) parseDC(req *http.Request, dc *string) { } } +// parseToken is used to parse the ?token query param +func (s *HTTPServer) parseToken(req *http.Request, token *string) { + if other := req.URL.Query().Get("token"); other != "" { + *token = other + } else if *token == "" { + *token = s.agent.config.ACLToken + } +} + // parse is a convenience method for endpoints that need // to use both parseWait and parseDC. func (s *HTTPServer) parse(resp http.ResponseWriter, req *http.Request, dc *string, b *structs.QueryOptions) bool { s.parseDC(req, dc) + s.parseToken(req, &b.Token) if parseConsistency(resp, req, b) { return true } diff --git a/command/agent/kvs_endpoint.go b/command/agent/kvs_endpoint.go index dfcce3025..48d9ce19d 100644 --- a/command/agent/kvs_endpoint.go +++ b/command/agent/kvs_endpoint.go @@ -145,6 +145,7 @@ func (s *HTTPServer) KVSPut(resp http.ResponseWriter, req *http.Request, args *s Value: nil, }, } + applyReq.Token = args.Token // Check for flags params := req.URL.Query() @@ -215,6 +216,7 @@ func (s *HTTPServer) KVSDelete(resp http.ResponseWriter, req *http.Request, args Key: args.Key, }, } + applyReq.Token = args.Token // Check for recurse params := req.URL.Query() diff --git a/consul/acl.go b/consul/acl.go new file mode 100644 index 000000000..abc011054 --- /dev/null +++ b/consul/acl.go @@ -0,0 +1,194 @@ +package consul + +import ( + "errors" + "strings" + "time" + + "github.com/armon/go-metrics" + "github.com/hashicorp/consul/acl" + "github.com/hashicorp/consul/consul/structs" +) + +const ( + // aclNotFound indicates there is no matching ACL + aclNotFound = "ACL not found" + + // rootDenied is returned when attempting to resolve a root ACL + rootDenied = "Cannot resolve root ACL" + + // permissionDenied is returned when an ACL based rejection happens + permissionDenied = "Permission denied" + + // aclDisabled is returned when ACL changes are not permitted + // since they are disabled. + aclDisabled = "ACL support disabled" + + // anonymousToken is the token ID we re-write to if there + // is no token ID provided + anonymousToken = "anonymous" +) + +var ( + permissionDeniedErr = errors.New(permissionDenied) +) + +// aclCacheEntry is used to cache non-authoritative ACL's +// If non-authoritative, then we must respect a TTL +type aclCacheEntry struct { + ACL acl.ACL + Expires time.Time + ETag string +} + +// aclFault is used to fault in the rules for an ACL if we take a miss +func (s *Server) aclFault(id string) (string, string, error) { + defer metrics.MeasureSince([]string{"consul", "acl", "fault"}, time.Now()) + state := s.fsm.State() + _, acl, err := state.ACLGet(id) + if err != nil { + return "", "", err + } + if acl == nil { + return "", "", errors.New(aclNotFound) + } + + // Management tokens have no policy and inherit from the + // 'manage' root policy + if acl.Type == structs.ACLTypeManagement { + return "manage", "", nil + } + + // Otherwise use the base policy + return s.config.ACLDefaultPolicy, acl.Rules, nil +} + +// resolveToken is used to resolve an ACL is any is appropriate +func (s *Server) resolveToken(id string) (acl.ACL, error) { + // Check if there is no ACL datacenter (ACL's disabled) + authDC := s.config.ACLDatacenter + if len(authDC) == 0 { + return nil, nil + } + defer metrics.MeasureSince([]string{"consul", "acl", "resolveToken"}, time.Now()) + + // Handle the anonymous token + if len(id) == 0 { + id = anonymousToken + } else if acl.RootACL(id) != nil { + return nil, errors.New(rootDenied) + } + + // Check if we are the ACL datacenter and the leader, use the + // authoritative cache + if s.config.Datacenter == authDC && s.IsLeader() { + return s.aclAuthCache.GetACL(id) + } + + // Use our non-authoritative cache + return s.lookupACL(id, authDC) +} + +// lookupACL is used when we are non-authoritative, and need +// to resolve an ACL +func (s *Server) lookupACL(id, authDC string) (acl.ACL, error) { + // Check the cache for the ACL + var cached *aclCacheEntry + raw, ok := s.aclCache.Get(id) + if ok { + cached = raw.(*aclCacheEntry) + } + + // Check for live cache + if cached != nil && time.Now().Before(cached.Expires) { + metrics.IncrCounter([]string{"consul", "acl", "cache_hit"}, 1) + return cached.ACL, nil + } else { + metrics.IncrCounter([]string{"consul", "acl", "cache_miss"}, 1) + } + + // Attempt to refresh the policy + args := structs.ACLPolicyRequest{ + Datacenter: authDC, + ACL: id, + } + if cached != nil { + args.ETag = cached.ETag + } + var out structs.ACLPolicy + err := s.RPC("ACL.GetPolicy", &args, &out) + + // Handle the happy path + if err == nil { + return s.useACLPolicy(id, authDC, cached, &out) + } + + // Check for not-found + if strings.Contains(err.Error(), aclNotFound) { + return nil, errors.New(aclNotFound) + } else { + s.logger.Printf("[ERR] consul.acl: Failed to get policy for '%s': %v", id, err) + } + + // Unable to refresh, apply the down policy + switch s.config.ACLDownPolicy { + case "allow": + return acl.AllowAll(), nil + case "extend-cache": + if cached != nil { + return cached.ACL, nil + } + fallthrough + default: + return acl.DenyAll(), nil + } +} + +// useACLPolicy handles an ACLPolicy response +func (s *Server) useACLPolicy(id, authDC string, cached *aclCacheEntry, p *structs.ACLPolicy) (acl.ACL, error) { + // Check if we can used the cached policy + if cached != nil && cached.ETag == p.ETag { + if p.TTL > 0 { + cached.Expires = time.Now().Add(p.TTL) + } + return cached.ACL, nil + } + + // Check for a cached compiled policy + var compiled acl.ACL + raw, ok := s.aclPolicyCache.Get(p.ETag) + if ok { + compiled = raw.(acl.ACL) + } else { + // Resolve the parent policy + parent := acl.RootACL(p.Parent) + if parent == nil { + var err error + parent, err = s.lookupACL(p.Parent, authDC) + if err != nil { + return nil, err + } + } + + // Compile the ACL + acl, err := acl.New(parent, p.Policy) + if err != nil { + return nil, err + } + + // Cache the policy + s.aclPolicyCache.Add(p.ETag, acl) + compiled = acl + } + + // Cache the ACL + cached = &aclCacheEntry{ + ACL: compiled, + ETag: p.ETag, + } + if p.TTL > 0 { + cached.Expires = time.Now().Add(p.TTL) + } + s.aclCache.Add(id, cached) + return compiled, nil +} diff --git a/consul/acl_endpoint.go b/consul/acl_endpoint.go new file mode 100644 index 000000000..211a78574 --- /dev/null +++ b/consul/acl_endpoint.go @@ -0,0 +1,175 @@ +package consul + +import ( + "fmt" + "time" + + "github.com/armon/go-metrics" + "github.com/hashicorp/consul/acl" + "github.com/hashicorp/consul/consul/structs" +) + +// ACL endpoint is used to manipulate ACLs +type ACL struct { + srv *Server +} + +// Apply is used to apply a modifying request to the data store. This should +// only be used for operations that modify the data +func (a *ACL) Apply(args *structs.ACLRequest, reply *string) error { + if done, err := a.srv.forward("ACL.Apply", args, args, reply); done { + return err + } + defer metrics.MeasureSince([]string{"consul", "acl", "apply"}, time.Now()) + + // Verify we are allowed to serve this request + if a.srv.config.ACLDatacenter != a.srv.config.Datacenter { + return fmt.Errorf(aclDisabled) + } + + // Verify token is permitted to list ACLs + if acl, err := a.srv.resolveToken(args.Token); err != nil { + return err + } else if acl == nil || !acl.ACLModify() { + return permissionDeniedErr + } + + switch args.Op { + case structs.ACLSet: + // Verify the ACL type + switch args.ACL.Type { + case structs.ACLTypeClient: + case structs.ACLTypeManagement: + default: + return fmt.Errorf("Invalid ACL Type") + } + + // Validate the rules compile + _, err := acl.Parse(args.ACL.Rules) + if err != nil { + return fmt.Errorf("ACL rule compilation failed: %v", err) + } + + case structs.ACLDelete: + if args.ACL.ID == "" { + return fmt.Errorf("Missing ACL ID") + } + + default: + return fmt.Errorf("Invalid ACL Operation") + } + + // Apply the update + resp, err := a.srv.raftApply(structs.ACLRequestType, args) + if err != nil { + a.srv.logger.Printf("[ERR] consul.acl: Apply failed: %v", err) + return err + } + if respErr, ok := resp.(error); ok { + return respErr + } + + // Clear the cache if applicable + if args.ACL.ID != "" { + a.srv.aclAuthCache.ClearACL(args.ACL.ID) + } + + // Check if the return type is a string + if respString, ok := resp.(string); ok { + *reply = respString + } + return nil +} + +// Get is used to retrieve a single ACL +func (a *ACL) Get(args *structs.ACLSpecificRequest, + reply *structs.IndexedACLs) error { + if done, err := a.srv.forward("ACL.Get", args, args, reply); done { + return err + } + + // Verify we are allowed to serve this request + if a.srv.config.ACLDatacenter != a.srv.config.Datacenter { + return fmt.Errorf(aclDisabled) + } + + // Get the local state + state := a.srv.fsm.State() + return a.srv.blockingRPC(&args.QueryOptions, + &reply.QueryMeta, + state.QueryTables("ACLGet"), + func() error { + index, acl, err := state.ACLGet(args.ACL) + reply.Index = index + if acl != nil { + reply.ACLs = structs.ACLs{acl} + } + return err + }) +} + +// GetPolicy is used to retrieve a compiled policy object with a TTL. Does not +// support a blocking query. +func (a *ACL) GetPolicy(args *structs.ACLPolicyRequest, reply *structs.ACLPolicy) error { + if done, err := a.srv.forward("ACL.GetPolicy", args, args, reply); done { + return err + } + + // Verify we are allowed to serve this request + if a.srv.config.ACLDatacenter != a.srv.config.Datacenter { + return fmt.Errorf(aclDisabled) + } + + // Get the policy via the cache + parent, policy, err := a.srv.aclAuthCache.GetACLPolicy(args.ACL) + if err != nil { + return err + } + + // Generate an ETag + conf := a.srv.config + etag := fmt.Sprintf("%s:%s", parent, policy.ID) + + // Setup the response + reply.ETag = etag + reply.TTL = conf.ACLTTL + a.srv.setQueryMeta(&reply.QueryMeta) + + // Only send the policy on an Etag mis-match + if args.ETag != etag { + reply.Parent = parent + reply.Policy = policy + } + return nil +} + +// List is used to list all the ACLs +func (a *ACL) List(args *structs.DCSpecificRequest, + reply *structs.IndexedACLs) error { + if done, err := a.srv.forward("ACL.List", args, args, reply); done { + return err + } + + // Verify we are allowed to serve this request + if a.srv.config.ACLDatacenter != a.srv.config.Datacenter { + return fmt.Errorf(aclDisabled) + } + + // Verify token is permitted to list ACLs + if acl, err := a.srv.resolveToken(args.Token); err != nil { + return err + } else if acl == nil || !acl.ACLList() { + return permissionDeniedErr + } + + // Get the local state + state := a.srv.fsm.State() + return a.srv.blockingRPC(&args.QueryOptions, + &reply.QueryMeta, + state.QueryTables("ACLList"), + func() error { + var err error + reply.Index, reply.ACLs, err = state.ACLList() + return err + }) +} diff --git a/consul/acl_endpoint_test.go b/consul/acl_endpoint_test.go new file mode 100644 index 000000000..18e1ddf38 --- /dev/null +++ b/consul/acl_endpoint_test.go @@ -0,0 +1,361 @@ +package consul + +import ( + "os" + "strings" + "testing" + "time" + + "github.com/hashicorp/consul/consul/structs" + "github.com/hashicorp/consul/testutil" +) + +func TestACLEndpoint_Apply(t *testing.T) { + dir1, s1 := testServerWithConfig(t, func(c *Config) { + c.ACLDatacenter = "dc1" + c.ACLMasterToken = "root" + }) + defer os.RemoveAll(dir1) + defer s1.Shutdown() + client := rpcClient(t, s1) + defer client.Close() + + testutil.WaitForLeader(t, client.Call, "dc1") + + arg := structs.ACLRequest{ + Datacenter: "dc1", + Op: structs.ACLSet, + ACL: structs.ACL{ + Name: "User token", + Type: structs.ACLTypeClient, + }, + WriteRequest: structs.WriteRequest{Token: "root"}, + } + var out string + if err := client.Call("ACL.Apply", &arg, &out); err != nil { + t.Fatalf("err: %v", err) + } + id := out + + // Verify + state := s1.fsm.State() + _, s, err := state.ACLGet(out) + if err != nil { + t.Fatalf("err: %v", err) + } + if s == nil { + t.Fatalf("should not be nil") + } + if s.ID != out { + t.Fatalf("bad: %v", s) + } + if s.Name != "User token" { + t.Fatalf("bad: %v", s) + } + + // Do a delete + arg.Op = structs.ACLDelete + arg.ACL.ID = out + if err := client.Call("ACL.Apply", &arg, &out); err != nil { + t.Fatalf("err: %v", err) + } + + // Verify + _, s, err = state.ACLGet(id) + if err != nil { + t.Fatalf("err: %v", err) + } + if s != nil { + t.Fatalf("bad: %v", s) + } +} + +func TestACLEndpoint_Update_PurgeCache(t *testing.T) { + dir1, s1 := testServerWithConfig(t, func(c *Config) { + c.ACLDatacenter = "dc1" + c.ACLMasterToken = "root" + }) + defer os.RemoveAll(dir1) + defer s1.Shutdown() + client := rpcClient(t, s1) + defer client.Close() + + testutil.WaitForLeader(t, client.Call, "dc1") + + arg := structs.ACLRequest{ + Datacenter: "dc1", + Op: structs.ACLSet, + ACL: structs.ACL{ + Name: "User token", + Type: structs.ACLTypeClient, + }, + WriteRequest: structs.WriteRequest{Token: "root"}, + } + var out string + if err := client.Call("ACL.Apply", &arg, &out); err != nil { + t.Fatalf("err: %v", err) + } + id := out + + // Resolve + acl1, err := s1.resolveToken(id) + if err != nil { + t.Fatalf("err: %v", err) + } + if acl1 == nil { + t.Fatalf("should not be nil") + } + if !acl1.KeyRead("foo") { + t.Fatalf("should be allowed") + } + + // Do an update + arg.ACL.ID = out + arg.ACL.Rules = `{"key": {"": {"policy": "deny"}}}` + if err := client.Call("ACL.Apply", &arg, &out); err != nil { + t.Fatalf("err: %v", err) + } + + // Resolve again + acl2, err := s1.resolveToken(id) + if err != nil { + t.Fatalf("err: %v", err) + } + if acl2 == nil { + t.Fatalf("should not be nil") + } + if acl2 == acl1 { + t.Fatalf("should not be cached") + } + if acl2.KeyRead("foo") { + t.Fatalf("should not be allowed") + } + + // Do a delete + arg.Op = structs.ACLDelete + arg.ACL.Rules = "" + if err := client.Call("ACL.Apply", &arg, &out); err != nil { + t.Fatalf("err: %v", err) + } + + // Resolve again + acl3, err := s1.resolveToken(id) + if err == nil || err.Error() != aclNotFound { + t.Fatalf("err: %v", err) + } + if acl3 != nil { + t.Fatalf("should be nil") + } +} + +func TestACLEndpoint_Apply_Denied(t *testing.T) { + dir1, s1 := testServerWithConfig(t, func(c *Config) { + c.ACLDatacenter = "dc1" + }) + defer os.RemoveAll(dir1) + defer s1.Shutdown() + client := rpcClient(t, s1) + defer client.Close() + + testutil.WaitForLeader(t, client.Call, "dc1") + + arg := structs.ACLRequest{ + Datacenter: "dc1", + Op: structs.ACLSet, + ACL: structs.ACL{ + Name: "User token", + Type: structs.ACLTypeClient, + }, + } + var out string + err := client.Call("ACL.Apply", &arg, &out) + if err == nil || !strings.Contains(err.Error(), permissionDenied) { + t.Fatalf("err: %v", err) + } +} + +func TestACLEndpoint_Get(t *testing.T) { + dir1, s1 := testServerWithConfig(t, func(c *Config) { + c.ACLDatacenter = "dc1" + c.ACLMasterToken = "root" + }) + defer os.RemoveAll(dir1) + defer s1.Shutdown() + client := rpcClient(t, s1) + defer client.Close() + + testutil.WaitForLeader(t, client.Call, "dc1") + + arg := structs.ACLRequest{ + Datacenter: "dc1", + Op: structs.ACLSet, + ACL: structs.ACL{ + Name: "User token", + Type: structs.ACLTypeClient, + }, + WriteRequest: structs.WriteRequest{Token: "root"}, + } + var out string + if err := client.Call("ACL.Apply", &arg, &out); err != nil { + t.Fatalf("err: %v", err) + } + + getR := structs.ACLSpecificRequest{ + Datacenter: "dc1", + ACL: out, + } + var acls structs.IndexedACLs + if err := client.Call("ACL.Get", &getR, &acls); err != nil { + t.Fatalf("err: %v", err) + } + + if acls.Index == 0 { + t.Fatalf("Bad: %v", acls) + } + if len(acls.ACLs) != 1 { + t.Fatalf("Bad: %v", acls) + } + s := acls.ACLs[0] + if s.ID != out { + t.Fatalf("bad: %v", s) + } +} + +func TestACLEndpoint_GetPolicy(t *testing.T) { + dir1, s1 := testServerWithConfig(t, func(c *Config) { + c.ACLDatacenter = "dc1" + c.ACLMasterToken = "root" + }) + defer os.RemoveAll(dir1) + defer s1.Shutdown() + client := rpcClient(t, s1) + defer client.Close() + + testutil.WaitForLeader(t, client.Call, "dc1") + + arg := structs.ACLRequest{ + Datacenter: "dc1", + Op: structs.ACLSet, + ACL: structs.ACL{ + Name: "User token", + Type: structs.ACLTypeClient, + }, + WriteRequest: structs.WriteRequest{Token: "root"}, + } + var out string + if err := client.Call("ACL.Apply", &arg, &out); err != nil { + t.Fatalf("err: %v", err) + } + + getR := structs.ACLPolicyRequest{ + Datacenter: "dc1", + ACL: out, + } + var acls structs.ACLPolicy + if err := client.Call("ACL.GetPolicy", &getR, &acls); err != nil { + t.Fatalf("err: %v", err) + } + + if acls.Policy == nil { + t.Fatalf("Bad: %v", acls) + } + if acls.TTL != 30*time.Second { + t.Fatalf("bad: %v", acls) + } + + // Do a conditional lookup with etag + getR.ETag = acls.ETag + var out2 structs.ACLPolicy + if err := client.Call("ACL.GetPolicy", &getR, &out2); err != nil { + t.Fatalf("err: %v", err) + } + + if out2.Policy != nil { + t.Fatalf("Bad: %v", out2) + } + if out2.TTL != 30*time.Second { + t.Fatalf("bad: %v", out2) + } +} + +func TestACLEndpoint_List(t *testing.T) { + dir1, s1 := testServerWithConfig(t, func(c *Config) { + c.ACLDatacenter = "dc1" + c.ACLMasterToken = "root" + }) + defer os.RemoveAll(dir1) + defer s1.Shutdown() + client := rpcClient(t, s1) + defer client.Close() + + testutil.WaitForLeader(t, client.Call, "dc1") + + ids := []string{} + for i := 0; i < 5; i++ { + arg := structs.ACLRequest{ + Datacenter: "dc1", + Op: structs.ACLSet, + ACL: structs.ACL{ + Name: "User token", + Type: structs.ACLTypeClient, + }, + WriteRequest: structs.WriteRequest{Token: "root"}, + } + var out string + if err := client.Call("ACL.Apply", &arg, &out); err != nil { + t.Fatalf("err: %v", err) + } + ids = append(ids, out) + } + + getR := structs.DCSpecificRequest{ + Datacenter: "dc1", + QueryOptions: structs.QueryOptions{Token: "root"}, + } + var acls structs.IndexedACLs + if err := client.Call("ACL.List", &getR, &acls); err != nil { + t.Fatalf("err: %v", err) + } + + if acls.Index == 0 { + t.Fatalf("Bad: %v", acls) + } + + // 5 + anonymous + master + if len(acls.ACLs) != 7 { + t.Fatalf("Bad: %v", acls.ACLs) + } + for i := 0; i < len(acls.ACLs); i++ { + s := acls.ACLs[i] + if s.ID == anonymousToken || s.ID == "root" { + continue + } + if !strContains(ids, s.ID) { + t.Fatalf("bad: %v", s) + } + if s.Name != "User token" { + t.Fatalf("bad: %v", s) + } + } +} + +func TestACLEndpoint_List_Denied(t *testing.T) { + dir1, s1 := testServerWithConfig(t, func(c *Config) { + c.ACLDatacenter = "dc1" + }) + defer os.RemoveAll(dir1) + defer s1.Shutdown() + client := rpcClient(t, s1) + defer client.Close() + + testutil.WaitForLeader(t, client.Call, "dc1") + + getR := structs.DCSpecificRequest{ + Datacenter: "dc1", + } + var acls structs.IndexedACLs + err := client.Call("ACL.List", &getR, &acls) + if err == nil || !strings.Contains(err.Error(), permissionDenied) { + t.Fatalf("err: %v", err) + } +} diff --git a/consul/acl_test.go b/consul/acl_test.go new file mode 100644 index 000000000..2fabcbee9 --- /dev/null +++ b/consul/acl_test.go @@ -0,0 +1,684 @@ +package consul + +import ( + "errors" + "fmt" + "os" + "testing" + + "github.com/hashicorp/consul/acl" + "github.com/hashicorp/consul/consul/structs" + "github.com/hashicorp/consul/testutil" +) + +func TestACL_Disabled(t *testing.T) { + dir1, s1 := testServer(t) + defer os.RemoveAll(dir1) + defer s1.Shutdown() + client := rpcClient(t, s1) + defer client.Close() + + testutil.WaitForLeader(t, client.Call, "dc1") + + acl, err := s1.resolveToken("does not exist") + if err != nil { + t.Fatalf("err: %v", err) + } + if acl != nil { + t.Fatalf("got acl") + } +} + +func TestACL_ResolveRootACL(t *testing.T) { + dir1, s1 := testServerWithConfig(t, func(c *Config) { + c.ACLDatacenter = "dc1" // Enable ACLs! + }) + defer os.RemoveAll(dir1) + defer s1.Shutdown() + + acl, err := s1.resolveToken("allow") + if err == nil || err.Error() != rootDenied { + t.Fatalf("err: %v", err) + } + if acl != nil { + t.Fatalf("bad: %v", acl) + } + + acl, err = s1.resolveToken("deny") + if err == nil || err.Error() != rootDenied { + t.Fatalf("err: %v", err) + } + if acl != nil { + t.Fatalf("bad: %v", acl) + } +} + +func TestACL_Authority_NotFound(t *testing.T) { + dir1, s1 := testServerWithConfig(t, func(c *Config) { + c.ACLDatacenter = "dc1" // Enable ACLs! + }) + defer os.RemoveAll(dir1) + defer s1.Shutdown() + client := rpcClient(t, s1) + defer client.Close() + + testutil.WaitForLeader(t, client.Call, "dc1") + + acl, err := s1.resolveToken("does not exist") + if err == nil || err.Error() != aclNotFound { + t.Fatalf("err: %v", err) + } + if acl != nil { + t.Fatalf("got acl") + } +} + +func TestACL_Authority_Found(t *testing.T) { + dir1, s1 := testServerWithConfig(t, func(c *Config) { + c.ACLDatacenter = "dc1" // Enable ACLs! + c.ACLMasterToken = "root" + }) + defer os.RemoveAll(dir1) + defer s1.Shutdown() + client := rpcClient(t, s1) + defer client.Close() + + testutil.WaitForLeader(t, client.Call, "dc1") + + // Create a new token + arg := structs.ACLRequest{ + Datacenter: "dc1", + Op: structs.ACLSet, + ACL: structs.ACL{ + Name: "User token", + Type: structs.ACLTypeClient, + Rules: testACLPolicy, + }, + WriteRequest: structs.WriteRequest{Token: "root"}, + } + var id string + if err := client.Call("ACL.Apply", &arg, &id); err != nil { + t.Fatalf("err: %v", err) + } + + // Resolve the token + acl, err := s1.resolveToken(id) + if err != nil { + t.Fatalf("err: %v", err) + } + if acl == nil { + t.Fatalf("missing acl") + } + + // Check the policy + if acl.KeyRead("bar") { + t.Fatalf("unexpected read") + } + if !acl.KeyRead("foo/test") { + t.Fatalf("unexpected failed read") + } +} + +func TestACL_Authority_Anonymous_Found(t *testing.T) { + dir1, s1 := testServerWithConfig(t, func(c *Config) { + c.ACLDatacenter = "dc1" // Enable ACLs! + }) + defer os.RemoveAll(dir1) + defer s1.Shutdown() + client := rpcClient(t, s1) + defer client.Close() + + testutil.WaitForLeader(t, client.Call, "dc1") + + // Resolve the token + acl, err := s1.resolveToken("") + if err != nil { + t.Fatalf("err: %v", err) + } + if acl == nil { + t.Fatalf("missing acl") + } + + // Check the policy, should allow all + if !acl.KeyRead("foo/test") { + t.Fatalf("unexpected failed read") + } +} + +func TestACL_Authority_Master_Found(t *testing.T) { + dir1, s1 := testServerWithConfig(t, func(c *Config) { + c.ACLDatacenter = "dc1" // Enable ACLs! + c.ACLMasterToken = "foobar" + }) + defer os.RemoveAll(dir1) + defer s1.Shutdown() + client := rpcClient(t, s1) + defer client.Close() + + testutil.WaitForLeader(t, client.Call, "dc1") + + // Resolve the token + acl, err := s1.resolveToken("foobar") + if err != nil { + t.Fatalf("err: %v", err) + } + if acl == nil { + t.Fatalf("missing acl") + } + + // Check the policy, should allow all + if !acl.KeyRead("foo/test") { + t.Fatalf("unexpected failed read") + } +} + +func TestACL_Authority_Management(t *testing.T) { + dir1, s1 := testServerWithConfig(t, func(c *Config) { + c.ACLDatacenter = "dc1" // Enable ACLs! + c.ACLMasterToken = "foobar" + c.ACLDefaultPolicy = "deny" + }) + defer os.RemoveAll(dir1) + defer s1.Shutdown() + client := rpcClient(t, s1) + defer client.Close() + + testutil.WaitForLeader(t, client.Call, "dc1") + + // Resolve the token + acl, err := s1.resolveToken("foobar") + if err != nil { + t.Fatalf("err: %v", err) + } + if acl == nil { + t.Fatalf("missing acl") + } + + // Check the policy, should allow all + if !acl.KeyRead("foo/test") { + t.Fatalf("unexpected failed read") + } +} + +func TestACL_NonAuthority_NotFound(t *testing.T) { + dir1, s1 := testServerWithConfig(t, func(c *Config) { + c.ACLDatacenter = "dc1" + }) + defer os.RemoveAll(dir1) + defer s1.Shutdown() + + dir2, s2 := testServerWithConfig(t, func(c *Config) { + c.ACLDatacenter = "dc1" // Enable ACLs! + c.Bootstrap = false // Disable bootstrap + }) + defer os.RemoveAll(dir2) + defer s2.Shutdown() + + // Try to join + addr := fmt.Sprintf("127.0.0.1:%d", + s1.config.SerfLANConfig.MemberlistConfig.BindPort) + if _, err := s2.JoinLAN([]string{addr}); err != nil { + t.Fatalf("err: %v", err) + } + + testutil.WaitForResult(func() (bool, error) { + p1, _ := s1.raftPeers.Peers() + return len(p1) == 2, errors.New(fmt.Sprintf("%v", p1)) + }, func(err error) { + t.Fatalf("should have 2 peers: %v", err) + }) + + client := rpcClient(t, s1) + defer client.Close() + testutil.WaitForLeader(t, client.Call, "dc1") + + // find the non-authoritative server + var nonAuth *Server + if !s1.IsLeader() { + nonAuth = s1 + } else { + nonAuth = s2 + } + + acl, err := nonAuth.resolveToken("does not exist") + if err == nil || err.Error() != aclNotFound { + t.Fatalf("err: %v", err) + } + if acl != nil { + t.Fatalf("got acl") + } +} + +func TestACL_NonAuthority_Found(t *testing.T) { + dir1, s1 := testServerWithConfig(t, func(c *Config) { + c.ACLDatacenter = "dc1" + c.ACLMasterToken = "root" + }) + defer os.RemoveAll(dir1) + defer s1.Shutdown() + client := rpcClient(t, s1) + defer client.Close() + + dir2, s2 := testServerWithConfig(t, func(c *Config) { + c.ACLDatacenter = "dc1" // Enable ACLs! + c.Bootstrap = false // Disable bootstrap + }) + defer os.RemoveAll(dir2) + defer s2.Shutdown() + + // Try to join + addr := fmt.Sprintf("127.0.0.1:%d", + s1.config.SerfLANConfig.MemberlistConfig.BindPort) + if _, err := s2.JoinLAN([]string{addr}); err != nil { + t.Fatalf("err: %v", err) + } + + testutil.WaitForResult(func() (bool, error) { + p1, _ := s1.raftPeers.Peers() + return len(p1) == 2, errors.New(fmt.Sprintf("%v", p1)) + }, func(err error) { + t.Fatalf("should have 2 peers: %v", err) + }) + testutil.WaitForLeader(t, client.Call, "dc1") + + // Create a new token + arg := structs.ACLRequest{ + Datacenter: "dc1", + Op: structs.ACLSet, + ACL: structs.ACL{ + Name: "User token", + Type: structs.ACLTypeClient, + Rules: testACLPolicy, + }, + WriteRequest: structs.WriteRequest{Token: "root"}, + } + var id string + if err := client.Call("ACL.Apply", &arg, &id); err != nil { + t.Fatalf("err: %v", err) + } + + // find the non-authoritative server + var nonAuth *Server + if !s1.IsLeader() { + nonAuth = s1 + } else { + nonAuth = s2 + } + + // Token should resolve + acl, err := nonAuth.resolveToken(id) + if err != nil { + t.Fatalf("err: %v", err) + } + if acl == nil { + t.Fatalf("missing acl") + } + + // Check the policy + if acl.KeyRead("bar") { + t.Fatalf("unexpected read") + } + if !acl.KeyRead("foo/test") { + t.Fatalf("unexpected failed read") + } +} + +func TestACL_NonAuthority_Management(t *testing.T) { + dir1, s1 := testServerWithConfig(t, func(c *Config) { + c.ACLDatacenter = "dc1" // Enable ACLs! + c.ACLMasterToken = "foobar" + c.ACLDefaultPolicy = "deny" + }) + defer os.RemoveAll(dir1) + defer s1.Shutdown() + client := rpcClient(t, s1) + defer client.Close() + + dir2, s2 := testServerWithConfig(t, func(c *Config) { + c.ACLDatacenter = "dc1" // Enable ACLs! + c.ACLDefaultPolicy = "deny" + c.Bootstrap = false // Disable bootstrap + }) + defer os.RemoveAll(dir2) + defer s2.Shutdown() + + // Try to join + addr := fmt.Sprintf("127.0.0.1:%d", + s1.config.SerfLANConfig.MemberlistConfig.BindPort) + if _, err := s2.JoinLAN([]string{addr}); err != nil { + t.Fatalf("err: %v", err) + } + + testutil.WaitForResult(func() (bool, error) { + p1, _ := s1.raftPeers.Peers() + return len(p1) == 2, errors.New(fmt.Sprintf("%v", p1)) + }, func(err error) { + t.Fatalf("should have 2 peers: %v", err) + }) + testutil.WaitForLeader(t, client.Call, "dc1") + + // find the non-authoritative server + var nonAuth *Server + if !s1.IsLeader() { + nonAuth = s1 + } else { + nonAuth = s2 + } + + // Resolve the token + acl, err := nonAuth.resolveToken("foobar") + if err != nil { + t.Fatalf("err: %v", err) + } + if acl == nil { + t.Fatalf("missing acl") + } + + // Check the policy, should allow all + if !acl.KeyRead("foo/test") { + t.Fatalf("unexpected failed read") + } +} + +func TestACL_DownPolicy_Deny(t *testing.T) { + dir1, s1 := testServerWithConfig(t, func(c *Config) { + c.ACLDatacenter = "dc1" + c.ACLDownPolicy = "deny" + c.ACLMasterToken = "root" + }) + defer os.RemoveAll(dir1) + defer s1.Shutdown() + client := rpcClient(t, s1) + defer client.Close() + + dir2, s2 := testServerWithConfig(t, func(c *Config) { + c.ACLDatacenter = "dc1" // Enable ACLs! + c.ACLDownPolicy = "deny" + c.Bootstrap = false // Disable bootstrap + }) + defer os.RemoveAll(dir2) + defer s2.Shutdown() + + // Try to join + addr := fmt.Sprintf("127.0.0.1:%d", + s1.config.SerfLANConfig.MemberlistConfig.BindPort) + if _, err := s2.JoinLAN([]string{addr}); err != nil { + t.Fatalf("err: %v", err) + } + + testutil.WaitForResult(func() (bool, error) { + p1, _ := s1.raftPeers.Peers() + return len(p1) == 2, errors.New(fmt.Sprintf("%v", p1)) + }, func(err error) { + t.Fatalf("should have 2 peers: %v", err) + }) + testutil.WaitForLeader(t, client.Call, "dc1") + + // Create a new token + arg := structs.ACLRequest{ + Datacenter: "dc1", + Op: structs.ACLSet, + ACL: structs.ACL{ + Name: "User token", + Type: structs.ACLTypeClient, + Rules: testACLPolicy, + }, + WriteRequest: structs.WriteRequest{Token: "root"}, + } + var id string + if err := client.Call("ACL.Apply", &arg, &id); err != nil { + t.Fatalf("err: %v", err) + } + + // find the non-authoritative server + var nonAuth *Server + var auth *Server + if !s1.IsLeader() { + nonAuth = s1 + auth = s2 + } else { + nonAuth = s2 + auth = s1 + } + + // Kill the authoritative server + auth.Shutdown() + + // Token should resolve into a DenyAll + aclR, err := nonAuth.resolveToken(id) + if err != nil { + t.Fatalf("err: %v", err) + } + if aclR != acl.DenyAll() { + t.Fatalf("bad acl: %#v", aclR) + } +} + +func TestACL_DownPolicy_Allow(t *testing.T) { + dir1, s1 := testServerWithConfig(t, func(c *Config) { + c.ACLDatacenter = "dc1" + c.ACLDownPolicy = "allow" + c.ACLMasterToken = "root" + }) + defer os.RemoveAll(dir1) + defer s1.Shutdown() + client := rpcClient(t, s1) + defer client.Close() + + dir2, s2 := testServerWithConfig(t, func(c *Config) { + c.ACLDatacenter = "dc1" // Enable ACLs! + c.ACLDownPolicy = "allow" + c.Bootstrap = false // Disable bootstrap + }) + defer os.RemoveAll(dir2) + defer s2.Shutdown() + + // Try to join + addr := fmt.Sprintf("127.0.0.1:%d", + s1.config.SerfLANConfig.MemberlistConfig.BindPort) + if _, err := s2.JoinLAN([]string{addr}); err != nil { + t.Fatalf("err: %v", err) + } + + testutil.WaitForResult(func() (bool, error) { + p1, _ := s1.raftPeers.Peers() + return len(p1) == 2, errors.New(fmt.Sprintf("%v", p1)) + }, func(err error) { + t.Fatalf("should have 2 peers: %v", err) + }) + testutil.WaitForLeader(t, client.Call, "dc1") + + // Create a new token + arg := structs.ACLRequest{ + Datacenter: "dc1", + Op: structs.ACLSet, + ACL: structs.ACL{ + Name: "User token", + Type: structs.ACLTypeClient, + Rules: testACLPolicy, + }, + WriteRequest: structs.WriteRequest{Token: "root"}, + } + var id string + if err := client.Call("ACL.Apply", &arg, &id); err != nil { + t.Fatalf("err: %v", err) + } + + // find the non-authoritative server + var nonAuth *Server + var auth *Server + if !s1.IsLeader() { + nonAuth = s1 + auth = s2 + } else { + nonAuth = s2 + auth = s1 + } + + // Kill the authoritative server + auth.Shutdown() + + // Token should resolve into a AllowAll + aclR, err := nonAuth.resolveToken(id) + if err != nil { + t.Fatalf("err: %v", err) + } + if aclR != acl.AllowAll() { + t.Fatalf("bad acl: %#v", aclR) + } +} + +func TestACL_DownPolicy_ExtendCache(t *testing.T) { + dir1, s1 := testServerWithConfig(t, func(c *Config) { + c.ACLDatacenter = "dc1" + c.ACLTTL = 0 + c.ACLDownPolicy = "extend-cache" + c.ACLMasterToken = "root" + }) + defer os.RemoveAll(dir1) + defer s1.Shutdown() + client := rpcClient(t, s1) + defer client.Close() + + dir2, s2 := testServerWithConfig(t, func(c *Config) { + c.ACLDatacenter = "dc1" // Enable ACLs! + c.ACLTTL = 0 + c.ACLDownPolicy = "extend-cache" + c.Bootstrap = false // Disable bootstrap + }) + defer os.RemoveAll(dir2) + defer s2.Shutdown() + + // Try to join + addr := fmt.Sprintf("127.0.0.1:%d", + s1.config.SerfLANConfig.MemberlistConfig.BindPort) + if _, err := s2.JoinLAN([]string{addr}); err != nil { + t.Fatalf("err: %v", err) + } + + testutil.WaitForResult(func() (bool, error) { + p1, _ := s1.raftPeers.Peers() + return len(p1) == 2, errors.New(fmt.Sprintf("%v", p1)) + }, func(err error) { + t.Fatalf("should have 2 peers: %v", err) + }) + testutil.WaitForLeader(t, client.Call, "dc1") + + // Create a new token + arg := structs.ACLRequest{ + Datacenter: "dc1", + Op: structs.ACLSet, + ACL: structs.ACL{ + Name: "User token", + Type: structs.ACLTypeClient, + Rules: testACLPolicy, + }, + WriteRequest: structs.WriteRequest{Token: "root"}, + } + var id string + if err := client.Call("ACL.Apply", &arg, &id); err != nil { + t.Fatalf("err: %v", err) + } + + // find the non-authoritative server + var nonAuth *Server + var auth *Server + if !s1.IsLeader() { + nonAuth = s1 + auth = s2 + } else { + nonAuth = s2 + auth = s1 + } + + // Warm the caches + aclR, err := nonAuth.resolveToken(id) + if err != nil { + t.Fatalf("err: %v", err) + } + if aclR == nil { + t.Fatalf("bad acl: %#v", aclR) + } + + // Kill the authoritative server + auth.Shutdown() + + // Token should resolve into cached copy + aclR2, err := nonAuth.resolveToken(id) + if err != nil { + t.Fatalf("err: %v", err) + } + if aclR2 != aclR { + t.Fatalf("bad acl: %#v", aclR) + } +} + +func TestACL_MultiDC_Found(t *testing.T) { + dir1, s1 := testServerWithConfig(t, func(c *Config) { + c.ACLDatacenter = "dc1" + c.ACLMasterToken = "root" + }) + defer os.RemoveAll(dir1) + defer s1.Shutdown() + client := rpcClient(t, s1) + defer client.Close() + + dir2, s2 := testServerWithConfig(t, func(c *Config) { + c.Datacenter = "dc2" + c.ACLDatacenter = "dc1" // Enable ACLs! + }) + defer os.RemoveAll(dir2) + defer s2.Shutdown() + + // Try to join + addr := fmt.Sprintf("127.0.0.1:%d", + s1.config.SerfWANConfig.MemberlistConfig.BindPort) + if _, err := s2.JoinWAN([]string{addr}); err != nil { + t.Fatalf("err: %v", err) + } + + testutil.WaitForLeader(t, client.Call, "dc1") + testutil.WaitForLeader(t, client.Call, "dc2") + + // Create a new token + arg := structs.ACLRequest{ + Datacenter: "dc1", + Op: structs.ACLSet, + ACL: structs.ACL{ + Name: "User token", + Type: structs.ACLTypeClient, + Rules: testACLPolicy, + }, + WriteRequest: structs.WriteRequest{Token: "root"}, + } + var id string + if err := client.Call("ACL.Apply", &arg, &id); err != nil { + t.Fatalf("err: %v", err) + } + + // Token should resolve + acl, err := s2.resolveToken(id) + if err != nil { + t.Fatalf("err: %v", err) + } + if acl == nil { + t.Fatalf("missing acl") + } + + // Check the policy + if acl.KeyRead("bar") { + t.Fatalf("unexpected read") + } + if !acl.KeyRead("foo/test") { + t.Fatalf("unexpected failed read") + } +} + +var testACLPolicy = ` +key "" { + policy = "deny" +} +key "foo/" { + policy = "write" +} +` diff --git a/consul/client.go b/consul/client.go index 92d923195..70626d2de 100644 --- a/consul/client.go +++ b/consul/client.go @@ -80,6 +80,11 @@ func NewClient(config *Config) (*Client, error) { return nil, fmt.Errorf("Config must provide a DataDir") } + // Sanity check the ACLs + if err := config.CheckACL(); err != nil { + return nil, err + } + // Ensure we have a log output if config.LogOutput == nil { config.LogOutput = os.Stderr diff --git a/consul/config.go b/consul/config.go index 8105e2e24..f49c8933a 100644 --- a/consul/config.go +++ b/consul/config.go @@ -128,6 +128,38 @@ type Config struct { // operators track which versions are actively deployed Build string + // ACLToken is the default token to use when making a request. + // If not provided, the anonymous token is used. This enables + // backwards compatibility as well. + ACLToken string + + // ACLMasterToken is used to bootstrap the ACL system. It should be specified + // on the servers in the ACLDatacenter. When the leader comes online, it ensures + // that the Master token is available. This provides the initial token. + ACLMasterToken string + + // ACLDatacenter provides the authoritative datacenter for ACL + // tokens. If not provided, ACL verification is disabled. + ACLDatacenter string + + // ACLTTL controls the time-to-live of cached ACL policies. + // It can be set to zero to disable caching, but this adds + // a substantial cost. + ACLTTL time.Duration + + // ACLDefaultPolicy is used to control the ACL interaction when + // there is no defined policy. This can be "allow" which means + // ACLs are used to black-list, or "deny" which means ACLs are + // white-lists. + ACLDefaultPolicy string + + // ACLDownPolicy controls the behavior of ACLs if the ACLDatacenter + // cannot be contacted. It can be either "deny" to deny all requests, + // or "extend-cache" which ignores the ACLCacheInterval and uses + // cached policies. If a policy is not in the cache, it acts like deny. + // "allow" can be used to allow all requests. This is not recommended. + ACLDownPolicy string + // ServerUp callback can be used to trigger a notification that // a Consul server is now up and known about. ServerUp func() @@ -145,6 +177,24 @@ func (c *Config) CheckVersion() error { return nil } +// CheckACL is used to sanity check the ACL configuration +func (c *Config) CheckACL() error { + switch c.ACLDefaultPolicy { + case "allow": + case "deny": + default: + return fmt.Errorf("Unsupported default ACL policy: %s", c.ACLDefaultPolicy) + } + switch c.ACLDownPolicy { + case "allow": + case "deny": + case "extend-cache": + default: + return fmt.Errorf("Unsupported down ACL policy: %s", c.ACLDownPolicy) + } + return nil +} + // AppendCA opens and parses the CA file and adds the certificates to // the provided CertPool. func (c *Config) AppendCA(pool *x509.CertPool) error { @@ -324,6 +374,9 @@ func DefaultConfig() *Config { SerfWANConfig: serf.DefaultConfig(), ReconcileInterval: 60 * time.Second, ProtocolVersion: ProtocolVersionMax, + ACLTTL: 30 * time.Second, + ACLDefaultPolicy: "allow", + ACLDownPolicy: "extend-cache", } // Increase our reap interval to 3 days instead of 24h. diff --git a/consul/filter.go b/consul/filter.go new file mode 100644 index 000000000..5577aa47a --- /dev/null +++ b/consul/filter.go @@ -0,0 +1,89 @@ +package consul + +import ( + "github.com/hashicorp/consul/acl" + "github.com/hashicorp/consul/consul/structs" +) + +type dirEntFilter struct { + acl acl.ACL + ent structs.DirEntries +} + +func (d *dirEntFilter) Len() int { + return len(d.ent) +} +func (d *dirEntFilter) Filter(i int) bool { + return !d.acl.KeyRead(d.ent[i].Key) +} +func (d *dirEntFilter) Move(dst, src, span int) { + copy(d.ent[dst:dst+span], d.ent[src:src+span]) +} + +// FilterDirEnt is used to filter a list of directory entries +// by applying an ACL policy +func FilterDirEnt(acl acl.ACL, ent structs.DirEntries) structs.DirEntries { + df := dirEntFilter{acl: acl, ent: ent} + return ent[:FilterEntries(&df)] +} + +type keyFilter struct { + acl acl.ACL + keys []string +} + +func (k *keyFilter) Len() int { + return len(k.keys) +} +func (k *keyFilter) Filter(i int) bool { + return !k.acl.KeyRead(k.keys[i]) +} + +func (k *keyFilter) Move(dst, src, span int) { + copy(k.keys[dst:dst+span], k.keys[src:src+span]) +} + +// FilterKeys is used to filter a list of keys by +// applying an ACL policy +func FilterKeys(acl acl.ACL, keys []string) []string { + kf := keyFilter{acl: acl, keys: keys} + return keys[:FilterEntries(&kf)] +} + +// Filter interfae is used with FilterEntries to do an +// in-place filter of a slice. +type Filter interface { + Len() int + Filter(int) bool + Move(dst, src, span int) +} + +// FilterEntries is used to do an inplace filter of +// a slice. This has cost proportional to the list length. +func FilterEntries(f Filter) int { + // Compact the list + dst := 0 + src := 0 + n := f.Len() + for dst < n { + for src < n && f.Filter(src) { + src++ + } + if src == n { + break + } + end := src + 1 + for end < n && !f.Filter(end) { + end++ + } + span := end - src + if span > 0 { + f.Move(dst, src, span) + dst += span + src += span + } + } + + // Return the size of the slice + return dst +} diff --git a/consul/filter_test.go b/consul/filter_test.go new file mode 100644 index 000000000..15feb1e68 --- /dev/null +++ b/consul/filter_test.go @@ -0,0 +1,96 @@ +package consul + +import ( + "reflect" + "testing" + + "github.com/hashicorp/consul/acl" + "github.com/hashicorp/consul/consul/structs" +) + +func TestFilterDirEnt(t *testing.T) { + policy, _ := acl.Parse(testFilterRules) + aclR, _ := acl.New(acl.DenyAll(), policy) + + type tcase struct { + in []string + out []string + } + cases := []tcase{ + tcase{ + in: []string{"foo/test", "foo/priv/nope", "foo/other", "zoo"}, + out: []string{"foo/test", "foo/other"}, + }, + tcase{ + in: []string{"abe", "lincoln"}, + out: nil, + }, + tcase{ + in: []string{"abe", "foo/1", "foo/2", "foo/3", "nope"}, + out: []string{"foo/1", "foo/2", "foo/3"}, + }, + } + + for _, tc := range cases { + ents := structs.DirEntries{} + for _, in := range tc.in { + ents = append(ents, &structs.DirEntry{Key: in}) + } + + ents = FilterDirEnt(aclR, ents) + var outL []string + for _, e := range ents { + outL = append(outL, e.Key) + } + + if !reflect.DeepEqual(outL, tc.out) { + t.Fatalf("bad: %#v %#v", outL, tc.out) + } + } +} + +func TestKeys(t *testing.T) { + policy, _ := acl.Parse(testFilterRules) + aclR, _ := acl.New(acl.DenyAll(), policy) + + type tcase struct { + in []string + out []string + } + cases := []tcase{ + tcase{ + in: []string{"foo/test", "foo/priv/nope", "foo/other", "zoo"}, + out: []string{"foo/test", "foo/other"}, + }, + tcase{ + in: []string{"abe", "lincoln"}, + out: nil, + }, + tcase{ + in: []string{"abe", "foo/1", "foo/2", "foo/3", "nope"}, + out: []string{"foo/1", "foo/2", "foo/3"}, + }, + } + + for _, tc := range cases { + out := FilterKeys(aclR, tc.in) + if !reflect.DeepEqual(out, tc.out) { + t.Fatalf("bad: %#v %#v", out, tc.out) + } + } +} + +var testFilterRules = ` +key "" { + policy = "deny" +} +key "foo/" { + policy = "read" +} +key "foo/priv/" { + policy = "deny" +} +key "zip/" { + policy = "read" +} +` diff --git a/consul/fsm.go b/consul/fsm.go index 8b4fd3d65..9810a289b 100644 --- a/consul/fsm.go +++ b/consul/fsm.go @@ -69,6 +69,8 @@ func (c *consulFSM) Apply(log *raft.Log) interface{} { return c.applyKVSOperation(buf[1:], log.Index) case structs.SessionRequestType: return c.applySessionOperation(buf[1:], log.Index) + case structs.ACLRequestType: + return c.applyACLOperation(buf[1:], log.Index) default: panic(fmt.Errorf("failed to apply request: %#v", buf)) } @@ -196,6 +198,33 @@ func (c *consulFSM) applySessionOperation(buf []byte, index uint64) interface{} return nil } +func (c *consulFSM) applyACLOperation(buf []byte, index uint64) interface{} { + var req structs.ACLRequest + if err := structs.Decode(buf, &req); err != nil { + panic(fmt.Errorf("failed to decode request: %v", err)) + } + switch req.Op { + case structs.ACLSet: + if err := c.state.ACLSet(index, &req.ACL, false); err != nil { + return err + } else { + return req.ACL.ID + } + case structs.ACLForceSet: + if err := c.state.ACLSet(index, &req.ACL, true); err != nil { + return err + } else { + return req.ACL.ID + } + case structs.ACLDelete: + return c.state.ACLDelete(index, req.ACL.ID) + default: + c.logger.Printf("[WARN] consul.fsm: Invalid ACL operation '%s'", req.Op) + return fmt.Errorf("Invalid ACL operation '%s'", req.Op) + } + return nil +} + func (c *consulFSM) Snapshot() (raft.FSMSnapshot, error) { defer func(start time.Time) { c.logger.Printf("[INFO] consul.fsm: snapshot created in %v", time.Now().Sub(start)) @@ -267,6 +296,15 @@ func (c *consulFSM) Restore(old io.ReadCloser) error { return err } + case structs.ACLRequestType: + var req structs.ACL + if err := dec.Decode(&req); err != nil { + return err + } + if err := c.state.ACLRestore(&req); err != nil { + return err + } + default: return fmt.Errorf("Unrecognized msg type: %v", msgType) } @@ -298,6 +336,11 @@ func (s *consulSnapshot) Persist(sink raft.SnapshotSink) error { return err } + if err := s.persistACLs(sink, encoder); err != nil { + sink.Cancel() + return err + } + if err := s.persistKV(sink, encoder); err != nil { sink.Cancel() return err @@ -364,6 +407,22 @@ func (s *consulSnapshot) persistSessions(sink raft.SnapshotSink, return nil } +func (s *consulSnapshot) persistACLs(sink raft.SnapshotSink, + encoder *codec.Encoder) error { + acls, err := s.state.ACLList() + if err != nil { + return err + } + + for _, s := range acls { + sink.Write([]byte{byte(structs.ACLRequestType)}) + if err := encoder.Encode(s); err != nil { + return err + } + } + return nil +} + func (s *consulSnapshot) persistKV(sink raft.SnapshotSink, encoder *codec.Encoder) error { streamCh := make(chan interface{}, 256) diff --git a/consul/fsm_test.go b/consul/fsm_test.go index 5e5d086d8..d188de9ff 100644 --- a/consul/fsm_test.go +++ b/consul/fsm_test.go @@ -328,6 +328,8 @@ func TestFSM_SnapshotRestore(t *testing.T) { }) session := &structs.Session{Node: "foo"} fsm.state.SessionCreate(9, session) + acl := &structs.ACL{Name: "User Token"} + fsm.state.ACLSet(10, acl, false) // Snapshot snap, err := fsm.Snapshot() @@ -392,7 +394,16 @@ func TestFSM_SnapshotRestore(t *testing.T) { t.Fatalf("err: %v", err) } if s.Node != "foo" { - t.Fatalf("bad: %v", d) + t.Fatalf("bad: %v", s) + } + + // Verify ACL is restored + _, a, err := fsm.state.ACLGet(acl.ID) + if err != nil { + t.Fatalf("err: %v", err) + } + if a.Name != "User Token" { + t.Fatalf("bad: %v", a) } } @@ -767,3 +778,75 @@ func TestFSM_KVSUnlock(t *testing.T) { t.Fatalf("bad: %v", *d) } } + +func TestFSM_ACL_Set_Delete(t *testing.T) { + fsm, err := NewFSM(os.Stderr) + if err != nil { + t.Fatalf("err: %v", err) + } + defer fsm.Close() + + // Create a new ACL + req := structs.ACLRequest{ + Datacenter: "dc1", + Op: structs.ACLSet, + ACL: structs.ACL{ + Name: "User token", + Type: structs.ACLTypeClient, + }, + } + buf, err := structs.Encode(structs.ACLRequestType, req) + if err != nil { + t.Fatalf("err: %v", err) + } + resp := fsm.Apply(makeLog(buf)) + if err, ok := resp.(error); ok { + t.Fatalf("resp: %v", err) + } + + // Get the ACL + id := resp.(string) + _, acl, err := fsm.state.ACLGet(id) + if err != nil { + t.Fatalf("err: %v", err) + } + if acl == nil { + t.Fatalf("missing") + } + + // Verify the ACL + if acl.ID != id { + t.Fatalf("bad: %v", *acl) + } + if acl.Name != "User token" { + t.Fatalf("bad: %v", *acl) + } + if acl.Type != structs.ACLTypeClient { + t.Fatalf("bad: %v", *acl) + } + + // Try to destroy + destroy := structs.ACLRequest{ + Datacenter: "dc1", + Op: structs.ACLDelete, + ACL: structs.ACL{ + ID: id, + }, + } + buf, err = structs.Encode(structs.ACLRequestType, destroy) + if err != nil { + t.Fatalf("err: %v", err) + } + resp = fsm.Apply(makeLog(buf)) + if resp != nil { + t.Fatalf("resp: %v", resp) + } + + _, acl, err = fsm.state.ACLGet(id) + if err != nil { + t.Fatalf("err: %v", err) + } + if acl != nil { + t.Fatalf("should be destroyed") + } +} diff --git a/consul/kvs_endpoint.go b/consul/kvs_endpoint.go index 91d8f3bde..53ed238be 100644 --- a/consul/kvs_endpoint.go +++ b/consul/kvs_endpoint.go @@ -2,9 +2,10 @@ package consul import ( "fmt" + "time" + "github.com/armon/go-metrics" "github.com/hashicorp/consul/consul/structs" - "time" ) // KVS endpoint is used to manipulate the Key-Value store @@ -25,6 +26,23 @@ func (k *KVS) Apply(args *structs.KVSRequest, reply *bool) error { return fmt.Errorf("Must provide key") } + // Apply the ACL policy if any + acl, err := k.srv.resolveToken(args.Token) + if err != nil { + return err + } else if acl != nil { + switch args.Op { + case structs.KVSDeleteTree: + if !acl.KeyWritePrefix(args.DirEnt.Key) { + return permissionDeniedErr + } + default: + if !acl.KeyWrite(args.DirEnt.Key) { + return permissionDeniedErr + } + } + } + // If this is a lock, we must check for a lock-delay. Since lock-delay // is based on wall-time, each peer expire the lock-delay at a slightly // different time. This means the enforcement of lock-delay cannot be done @@ -65,6 +83,11 @@ func (k *KVS) Get(args *structs.KeyRequest, reply *structs.IndexedDirEntries) er return err } + acl, err := k.srv.resolveToken(args.Token) + if err != nil { + return err + } + // Get the local state state := k.srv.fsm.State() return k.srv.blockingRPC(&args.QueryOptions, @@ -75,6 +98,9 @@ func (k *KVS) Get(args *structs.KeyRequest, reply *structs.IndexedDirEntries) er if err != nil { return err } + if acl != nil && !acl.KeyRead(args.Key) { + ent = nil + } if ent == nil { // Must provide non-zero index to prevent blocking // Index 1 is impossible anyways (due to Raft internals) @@ -98,6 +124,11 @@ func (k *KVS) List(args *structs.KeyRequest, reply *structs.IndexedDirEntries) e return err } + acl, err := k.srv.resolveToken(args.Token) + if err != nil { + return err + } + // Get the local state state := k.srv.fsm.State() return k.srv.blockingRPC(&args.QueryOptions, @@ -108,6 +139,9 @@ func (k *KVS) List(args *structs.KeyRequest, reply *structs.IndexedDirEntries) e if err != nil { return err } + if acl != nil { + ent = FilterDirEnt(acl, ent) + } if len(ent) == 0 { // Must provide non-zero index to prevent blocking // Index 1 is impossible anyways (due to Raft internals) @@ -139,14 +173,23 @@ func (k *KVS) ListKeys(args *structs.KeyListRequest, reply *structs.IndexedKeyLi return err } + acl, err := k.srv.resolveToken(args.Token) + if err != nil { + return err + } + // Get the local state state := k.srv.fsm.State() return k.srv.blockingRPC(&args.QueryOptions, &reply.QueryMeta, state.QueryTables("KVSListKeys"), func() error { - var err error - reply.Index, reply.Keys, err = state.KVSListKeys(args.Prefix, args.Seperator) + index, keys, err := state.KVSListKeys(args.Prefix, args.Seperator) + reply.Index = index + if acl != nil { + keys = FilterKeys(acl, keys) + } + reply.Keys = keys return err }) } diff --git a/consul/kvs_endpoint_test.go b/consul/kvs_endpoint_test.go index c4131fdcb..3a3769825 100644 --- a/consul/kvs_endpoint_test.go +++ b/consul/kvs_endpoint_test.go @@ -1,11 +1,13 @@ package consul import ( - "github.com/hashicorp/consul/consul/structs" - "github.com/hashicorp/consul/testutil" "os" + "strings" "testing" "time" + + "github.com/hashicorp/consul/consul/structs" + "github.com/hashicorp/consul/testutil" ) func TestKVS_Apply(t *testing.T) { @@ -64,6 +66,68 @@ func TestKVS_Apply(t *testing.T) { } } +func TestKVS_Apply_ACLDeny(t *testing.T) { + dir1, s1 := testServerWithConfig(t, func(c *Config) { + c.ACLDatacenter = "dc1" + c.ACLMasterToken = "root" + c.ACLDefaultPolicy = "deny" + }) + defer os.RemoveAll(dir1) + defer s1.Shutdown() + client := rpcClient(t, s1) + defer client.Close() + + testutil.WaitForLeader(t, client.Call, "dc1") + + // Create the ACL + arg := structs.ACLRequest{ + Datacenter: "dc1", + Op: structs.ACLSet, + ACL: structs.ACL{ + Name: "User token", + Type: structs.ACLTypeClient, + Rules: testListRules, + }, + WriteRequest: structs.WriteRequest{Token: "root"}, + } + var out string + if err := client.Call("ACL.Apply", &arg, &out); err != nil { + t.Fatalf("err: %v", err) + } + id := out + + // Try a write + argR := structs.KVSRequest{ + Datacenter: "dc1", + Op: structs.KVSSet, + DirEnt: structs.DirEntry{ + Key: "foo/bar", + Flags: 42, + Value: []byte("test"), + }, + WriteRequest: structs.WriteRequest{Token: id}, + } + var outR bool + err := client.Call("KVS.Apply", &argR, &outR) + if err == nil || !strings.Contains(err.Error(), permissionDenied) { + t.Fatalf("err: %v", err) + } + + // Try a recursive delete + argR = structs.KVSRequest{ + Datacenter: "dc1", + Op: structs.KVSDeleteTree, + DirEnt: structs.DirEntry{ + Key: "test", + }, + WriteRequest: structs.WriteRequest{Token: id}, + } + err = client.Call("KVS.Apply", &argR, &outR) + if err == nil || !strings.Contains(err.Error(), permissionDenied) { + t.Fatalf("err: %v", err) + } +} + func TestKVS_Get(t *testing.T) { dir1, s1 := testServer(t) defer os.RemoveAll(dir1) @@ -111,6 +175,51 @@ func TestKVS_Get(t *testing.T) { } } +func TestKVS_Get_ACLDeny(t *testing.T) { + dir1, s1 := testServerWithConfig(t, func(c *Config) { + c.ACLDatacenter = "dc1" + c.ACLMasterToken = "root" + c.ACLDefaultPolicy = "deny" + }) + defer os.RemoveAll(dir1) + defer s1.Shutdown() + client := rpcClient(t, s1) + defer client.Close() + + testutil.WaitForLeader(t, client.Call, "dc1") + + arg := structs.KVSRequest{ + Datacenter: "dc1", + Op: structs.KVSSet, + DirEnt: structs.DirEntry{ + Key: "zip", + Flags: 42, + Value: []byte("test"), + }, + WriteRequest: structs.WriteRequest{Token: "root"}, + } + var out bool + if err := client.Call("KVS.Apply", &arg, &out); err != nil { + t.Fatalf("err: %v", err) + } + + getR := structs.KeyRequest{ + Datacenter: "dc1", + Key: "zip", + } + var dirent structs.IndexedDirEntries + if err := client.Call("KVS.Get", &getR, &dirent); err != nil { + t.Fatalf("err: %v", err) + } + + if dirent.Index == 0 { + t.Fatalf("Bad: %v", dirent) + } + if len(dirent.Entries) != 0 { + t.Fatalf("Bad: %v", dirent) + } +} + func TestKVSEndpoint_List(t *testing.T) { dir1, s1 := testServer(t) defer os.RemoveAll(dir1) @@ -170,6 +279,90 @@ func TestKVSEndpoint_List(t *testing.T) { } } +func TestKVSEndpoint_List_ACLDeny(t *testing.T) { + dir1, s1 := testServerWithConfig(t, func(c *Config) { + c.ACLDatacenter = "dc1" + c.ACLMasterToken = "root" + c.ACLDefaultPolicy = "deny" + }) + defer os.RemoveAll(dir1) + defer s1.Shutdown() + client := rpcClient(t, s1) + defer client.Close() + + testutil.WaitForLeader(t, client.Call, "dc1") + + keys := []string{ + "abe", + "bar", + "foo", + "test", + "zip", + } + + for _, key := range keys { + arg := structs.KVSRequest{ + Datacenter: "dc1", + Op: structs.KVSSet, + DirEnt: structs.DirEntry{ + Key: key, + Flags: 1, + }, + WriteRequest: structs.WriteRequest{Token: "root"}, + } + var out bool + if err := client.Call("KVS.Apply", &arg, &out); err != nil { + t.Fatalf("err: %v", err) + } + } + + arg := structs.ACLRequest{ + Datacenter: "dc1", + Op: structs.ACLSet, + ACL: structs.ACL{ + Name: "User token", + Type: structs.ACLTypeClient, + Rules: testListRules, + }, + WriteRequest: structs.WriteRequest{Token: "root"}, + } + var out string + if err := client.Call("ACL.Apply", &arg, &out); err != nil { + t.Fatalf("err: %v", err) + } + id := out + + getR := structs.KeyRequest{ + Datacenter: "dc1", + Key: "", + QueryOptions: structs.QueryOptions{Token: id}, + } + var dirent structs.IndexedDirEntries + if err := client.Call("KVS.List", &getR, &dirent); err != nil { + t.Fatalf("err: %v", err) + } + + if dirent.Index == 0 { + t.Fatalf("Bad: %v", dirent) + } + if len(dirent.Entries) != 2 { + t.Fatalf("Bad: %v", dirent.Entries) + } + for i := 0; i < len(dirent.Entries); i++ { + d := dirent.Entries[i] + switch i { + case 0: + if d.Key != "foo" { + t.Fatalf("bad key") + } + case 1: + if d.Key != "test" { + t.Fatalf("bad key") + } + } + } +} + func TestKVSEndpoint_ListKeys(t *testing.T) { dir1, s1 := testServer(t) defer os.RemoveAll(dir1) @@ -227,6 +420,84 @@ func TestKVSEndpoint_ListKeys(t *testing.T) { } } +func TestKVSEndpoint_ListKeys_ACLDeny(t *testing.T) { + dir1, s1 := testServerWithConfig(t, func(c *Config) { + c.ACLDatacenter = "dc1" + c.ACLMasterToken = "root" + c.ACLDefaultPolicy = "deny" + }) + defer os.RemoveAll(dir1) + defer s1.Shutdown() + client := rpcClient(t, s1) + defer client.Close() + + testutil.WaitForLeader(t, client.Call, "dc1") + + keys := []string{ + "abe", + "bar", + "foo", + "test", + "zip", + } + + for _, key := range keys { + arg := structs.KVSRequest{ + Datacenter: "dc1", + Op: structs.KVSSet, + DirEnt: structs.DirEntry{ + Key: key, + Flags: 1, + }, + WriteRequest: structs.WriteRequest{Token: "root"}, + } + var out bool + if err := client.Call("KVS.Apply", &arg, &out); err != nil { + t.Fatalf("err: %v", err) + } + } + + arg := structs.ACLRequest{ + Datacenter: "dc1", + Op: structs.ACLSet, + ACL: structs.ACL{ + Name: "User token", + Type: structs.ACLTypeClient, + Rules: testListRules, + }, + WriteRequest: structs.WriteRequest{Token: "root"}, + } + var out string + if err := client.Call("ACL.Apply", &arg, &out); err != nil { + t.Fatalf("err: %v", err) + } + id := out + + getR := structs.KeyListRequest{ + Datacenter: "dc1", + Prefix: "", + Seperator: "/", + QueryOptions: structs.QueryOptions{Token: id}, + } + var dirent structs.IndexedKeyList + if err := client.Call("KVS.ListKeys", &getR, &dirent); err != nil { + t.Fatalf("err: %v", err) + } + + if dirent.Index == 0 { + t.Fatalf("Bad: %v", dirent) + } + if len(dirent.Keys) != 2 { + t.Fatalf("Bad: %v", dirent.Keys) + } + if dirent.Keys[0] != "foo" { + t.Fatalf("Bad: %v", dirent.Keys) + } + if dirent.Keys[1] != "test" { + t.Fatalf("Bad: %v", dirent.Keys) + } +} + func TestKVS_Apply_LockDelay(t *testing.T) { dir1, s1 := testServer(t) defer os.RemoveAll(dir1) @@ -294,3 +565,18 @@ func TestKVS_Apply_LockDelay(t *testing.T) { t.Fatalf("should acquire") } } + +var testListRules = ` +key "" { + policy = "deny" +} +key "foo" { + policy = "read" +} +key "test" { + policy = "write" +} +key "test/priv" { + policy = "read" +} +` diff --git a/consul/leader.go b/consul/leader.go index b63f7bbe8..60a6737f9 100644 --- a/consul/leader.go +++ b/consul/leader.go @@ -1,6 +1,7 @@ package consul import ( + "fmt" "net" "strconv" "time" @@ -54,6 +55,11 @@ func (s *Server) leaderLoop(stopCh chan struct{}) { s.logger.Printf("[WARN] consul: failed to broadcast new leader event: %v", err) } + // Setup ACLs if we are the leader and need to + if err := s.initializeACL(); err != nil { + s.logger.Printf("[ERR] consul: ACL initialization failed: %v", err) + } + // Reconcile channel is only used once initial reconcile // has succeeded var reconcileCh chan serf.Member @@ -99,6 +105,73 @@ WAIT: } } +// initializeACL is used to setup the ACLs if we are the leader +// and need to do this. +func (s *Server) initializeACL() error { + // Bail if not configured or we are not authoritative + authDC := s.config.ACLDatacenter + if len(authDC) == 0 || authDC != s.config.Datacenter { + return nil + } + + // Purge the cache, since it could've changed while we + // were not the leader + s.aclAuthCache.Purge() + + // Look for the anonymous token + state := s.fsm.State() + _, acl, err := state.ACLGet(anonymousToken) + if err != nil { + return fmt.Errorf("failed to get anonymous token: %v", err) + } + + // Create anonymous token if missing + if acl == nil { + req := structs.ACLRequest{ + Datacenter: authDC, + Op: structs.ACLForceSet, + ACL: structs.ACL{ + ID: anonymousToken, + Name: "Anonymous Token", + Type: structs.ACLTypeClient, + }, + } + _, err := s.raftApply(structs.ACLRequestType, &req) + if err != nil { + return fmt.Errorf("failed to create anonymous token: %v", err) + } + } + + // Check for configured master token + master := s.config.ACLMasterToken + if len(master) == 0 { + return nil + } + + // Look for the master token + _, acl, err = state.ACLGet(master) + if err != nil { + return fmt.Errorf("failed to get master token: %v", err) + } + if acl == nil { + req := structs.ACLRequest{ + Datacenter: authDC, + Op: structs.ACLForceSet, + ACL: structs.ACL{ + ID: master, + Name: "Master Token", + Type: structs.ACLTypeManagement, + }, + } + _, err := s.raftApply(structs.ACLRequestType, &req) + if err != nil { + return fmt.Errorf("failed to create master token: %v", err) + } + + } + return nil +} + // reconcile is used to reconcile the differences between Serf // membership and what is reflected in our strongly consistent store. // Mainly we need to ensure all live nodes are registered, all failed diff --git a/consul/server.go b/consul/server.go index af61dc94c..d3fe6da92 100644 --- a/consul/server.go +++ b/consul/server.go @@ -15,6 +15,8 @@ import ( "sync" "time" + "github.com/hashicorp/consul/acl" + "github.com/hashicorp/golang-lru" "github.com/hashicorp/raft" "github.com/hashicorp/raft-mdb" "github.com/hashicorp/serf/serf" @@ -43,11 +45,24 @@ const ( // serverMaxStreams controsl how many idle streams we keep // open to a server serverMaxStreams = 64 + + // Maximum number of cached ACL entries + aclCacheSize = 256 ) // Server is Consul server which manages the service discovery, // health checking, DC forwarding, Raft, and multiple Serf pools. type Server struct { + // aclAuthCache is the authoritative ACL cache + aclAuthCache *acl.Cache + + // aclCache is a non-authoritative ACL cache + aclCache *lru.Cache + + // aclPolicyCache is a policy cache + aclPolicyCache *lru.Cache + + // Consul configuration config *Config // Connection pool to other consul servers @@ -125,6 +140,7 @@ type endpoints struct { KVS *KVS Session *Session Internal *Internal + ACL *ACL } // NewServer is used to construct a new Consul server from the @@ -140,6 +156,11 @@ func NewServer(config *Config) (*Server, error) { return nil, fmt.Errorf("Config must provide a DataDir") } + // Sanity check the ACLs + if err := config.CheckACL(); err != nil { + return nil, err + } + // Ensure we have a log output if config.LogOutput == nil { config.LogOutput = os.Stderr @@ -175,6 +196,27 @@ func NewServer(config *Config) (*Server, error) { shutdownCh: make(chan struct{}), } + // Initialize the authoritative ACL cache + s.aclAuthCache, err = acl.NewCache(aclCacheSize, s.aclFault) + if err != nil { + s.Shutdown() + return nil, fmt.Errorf("Failed to create ACL cache: %v", err) + } + + // Initialize the non-authoritative ACL cache + s.aclCache, err = lru.New(aclCacheSize) + if err != nil { + s.Shutdown() + return nil, fmt.Errorf("Failed to create ACL cache: %v", err) + } + + // Initialize the ACL policy cache + s.aclPolicyCache, err = lru.New(aclCacheSize) + if err != nil { + s.Shutdown() + return nil, fmt.Errorf("Failed to create ACL policy cache: %v", err) + } + // Initialize the RPC layer if err := s.setupRPC(tlsConfig); err != nil { s.Shutdown() @@ -336,6 +378,7 @@ func (s *Server) setupRPC(tlsConfig *tls.Config) error { s.endpoints.KVS = &KVS{s} s.endpoints.Session = &Session{s} s.endpoints.Internal = &Internal{s} + s.endpoints.ACL = &ACL{s} // Register the handlers s.rpcServer.Register(s.endpoints.Status) @@ -344,6 +387,7 @@ func (s *Server) setupRPC(tlsConfig *tls.Config) error { s.rpcServer.Register(s.endpoints.KVS) s.rpcServer.Register(s.endpoints.Session) s.rpcServer.Register(s.endpoints.Internal) + s.rpcServer.Register(s.endpoints.ACL) list, err := net.ListenTCP("tcp", s.config.RPCAddr) if err != nil { diff --git a/consul/server_test.go b/consul/server_test.go index 70aa5811f..76b7d4ed4 100644 --- a/consul/server_test.go +++ b/consul/server_test.go @@ -101,6 +101,17 @@ func testServerDCExpect(t *testing.T, dc string, expect int) (string, *Server) { return dir, server } +func testServerWithConfig(t *testing.T, cb func(c *Config)) (string, *Server) { + name := fmt.Sprintf("Node %d", getPort()) + dir, config := testServerConfig(t, name) + cb(config) + server, err := NewServer(config) + if err != nil { + t.Fatalf("err: %v", err) + } + return dir, server +} + func TestServer_StartStop(t *testing.T) { dir := tmpDir(t) defer os.RemoveAll(dir) diff --git a/consul/state_store.go b/consul/state_store.go index f95b0554e..6a43aa027 100644 --- a/consul/state_store.go +++ b/consul/state_store.go @@ -21,6 +21,7 @@ const ( dbKVS = "kvs" dbSessions = "sessions" dbSessionChecks = "sessionChecks" + dbACLs = "acls" dbMaxMapSize32bit uint64 = 512 * 1024 * 1024 // 512MB maximum size dbMaxMapSize64bit uint64 = 32 * 1024 * 1024 * 1024 // 32GB maximum size ) @@ -53,6 +54,7 @@ type StateStore struct { kvsTable *MDBTable sessionTable *MDBTable sessionCheckTable *MDBTable + aclTable *MDBTable tables MDBTables watch map[*MDBTable]*NotifyGroup queryTables map[string]MDBTables @@ -306,9 +308,26 @@ func (s *StateStore) initialize() error { }, } + s.aclTable = &MDBTable{ + Name: dbACLs, + Indexes: map[string]*MDBIndex{ + "id": &MDBIndex{ + Unique: true, + Fields: []string{"ID"}, + }, + }, + Decoder: func(buf []byte) interface{} { + out := new(structs.ACL) + if err := structs.Decode(buf, out); err != nil { + panic(err) + } + return out + }, + } + // Store the set of tables s.tables = []*MDBTable{s.nodeTable, s.serviceTable, s.checkTable, - s.kvsTable, s.sessionTable, s.sessionCheckTable} + s.kvsTable, s.sessionTable, s.sessionCheckTable, s.aclTable} for _, table := range s.tables { table.Env = s.env table.Encoder = encoder @@ -338,6 +357,8 @@ func (s *StateStore) initialize() error { "SessionGet": MDBTables{s.sessionTable}, "SessionList": MDBTables{s.sessionTable}, "NodeSessions": MDBTables{s.sessionTable}, + "ACLGet": MDBTables{s.aclTable}, + "ACLList": MDBTables{s.aclTable}, } return nil } @@ -1249,8 +1270,8 @@ func (s *StateStore) SessionCreate(index uint64, session *structs.Session) error } // Generate a new session ID, verify uniqueness - session.ID = generateUUID() for { + session.ID = generateUUID() res, err = s.sessionTable.GetTxn(tx, "id", session.ID) if err != nil { return err @@ -1346,7 +1367,7 @@ func (s *StateStore) NodeSessions(node string) (uint64, []*structs.Session, erro return idx, out, err } -// SessionDelete is used to destroy a session. +// SessionDestroy is used to destroy a session. func (s *StateStore) SessionDestroy(index uint64, id string) error { tx, err := s.tables.StartTxn(false) if err != nil { @@ -1482,6 +1503,124 @@ func (s *StateStore) invalidateLocks(index uint64, tx *MDBTxn, return nil } +// ACLSet is used to create or update an ACL entry +// allowCreate is used for initialization of the anonymous and master tokens, +// since it permits them to be created with a specified ID that does not exist. +func (s *StateStore) ACLSet(index uint64, acl *structs.ACL, allowCreate bool) error { + // Start a new txn + tx, err := s.tables.StartTxn(false) + if err != nil { + return err + } + defer tx.Abort() + + // Generate a new session ID + if acl.ID == "" { + for { + acl.ID = generateUUID() + res, err := s.aclTable.GetTxn(tx, "id", acl.ID) + if err != nil { + return err + } + // Quit if this ID is unique + if len(res) == 0 { + break + } + } + acl.CreateIndex = index + acl.ModifyIndex = index + + } else { + // Look for the existing node + res, err := s.aclTable.GetTxn(tx, "id", acl.ID) + if err != nil { + return err + } + + switch len(res) { + case 0: + if !allowCreate { + return fmt.Errorf("Invalid ACL") + } + acl.CreateIndex = index + acl.ModifyIndex = index + case 1: + exist := res[0].(*structs.ACL) + acl.CreateIndex = exist.CreateIndex + acl.ModifyIndex = index + default: + panic(fmt.Errorf("Duplicate ACL definition. Internal error")) + } + } + + // Insert the ACL + if err := s.aclTable.InsertTxn(tx, acl); err != nil { + return err + } + + // Trigger the update notifications + if err := s.aclTable.SetLastIndexTxn(tx, index); err != nil { + return err + } + tx.Defer(func() { s.watch[s.aclTable].Notify() }) + return tx.Commit() +} + +// ACLRestore is used to restore an ACL. It should only be used when +// doing a restore, otherwise ACLSet should be used. +func (s *StateStore) ACLRestore(acl *structs.ACL) error { + // Start a new txn + tx, err := s.aclTable.StartTxn(false, nil) + if err != nil { + return err + } + defer tx.Abort() + + if err := s.aclTable.InsertTxn(tx, acl); err != nil { + return err + } + return tx.Commit() +} + +// ACLGet is used to get an ACL by ID +func (s *StateStore) ACLGet(id string) (uint64, *structs.ACL, error) { + idx, res, err := s.aclTable.Get("id", id) + var d *structs.ACL + if len(res) > 0 { + d = res[0].(*structs.ACL) + } + return idx, d, err +} + +// ACLList is used to list all the acls +func (s *StateStore) ACLList() (uint64, []*structs.ACL, error) { + idx, res, err := s.aclTable.Get("id") + out := make([]*structs.ACL, len(res)) + for i, raw := range res { + out[i] = raw.(*structs.ACL) + } + return idx, out, err +} + +// ACLDelete is used to remove an ACL +func (s *StateStore) ACLDelete(index uint64, id string) error { + tx, err := s.tables.StartTxn(false) + if err != nil { + panic(fmt.Errorf("Failed to start txn: %v", err)) + } + defer tx.Abort() + + if n, err := s.aclTable.DeleteTxn(tx, "id", id); err != nil { + return err + } else if n > 0 { + if err := s.aclTable.SetLastIndexTxn(tx, index); err != nil { + return err + } + tx.Defer(func() { s.watch[s.aclTable].Notify() }) + } + return tx.Commit() +} + // Snapshot is used to create a point in time snapshot func (s *StateStore) Snapshot() (*StateSnapshot, error) { // Begin a new txn on all tables @@ -1555,3 +1694,13 @@ func (s *StateSnapshot) SessionList() ([]*structs.Session, error) { } return out, err } + +// ACLList is used to list all of the ACLs +func (s *StateSnapshot) ACLList() ([]*structs.ACL, error) { + res, err := s.store.aclTable.GetTxn(s.tx, "id") + out := make([]*structs.ACL, len(res)) + for i, raw := range res { + out[i] = raw.(*structs.ACL) + } + return out, err +} diff --git a/consul/state_store_test.go b/consul/state_store_test.go index a5130131a..f0cdae90f 100644 --- a/consul/state_store_test.go +++ b/consul/state_store_test.go @@ -652,6 +652,22 @@ func TestStoreSnapshot(t *testing.T) { t.Fatalf("err: %v", err) } + a1 := &structs.ACL{ + Name: "User token", + Type: structs.ACLTypeClient, + } + if err := store.ACLSet(19, a1, false); err != nil { + t.Fatalf("err: %v", err) + } + + a2 := &structs.ACL{ + Name: "User token", + Type: structs.ACLTypeClient, + } + if err := store.ACLSet(20, a2, false); err != nil { + t.Fatalf("err: %v", err) + } + // Take a snapshot snap, err := store.Snapshot() if err != nil { @@ -660,7 +676,7 @@ func TestStoreSnapshot(t *testing.T) { defer snap.Close() // Check the last nodes - if idx := snap.LastIndex(); idx != 18 { + if idx := snap.LastIndex(); idx != 20 { t.Fatalf("bad: %v", idx) } @@ -724,14 +740,23 @@ func TestStoreSnapshot(t *testing.T) { t.Fatalf("missing sessions") } + // Check for an acl + acls, err := snap.ACLList() + if err != nil { + t.Fatalf("err: %v", err) + } + if len(acls) != 2 { + t.Fatalf("missing acls") + } + // Make some changes! - if err := store.EnsureService(19, "foo", &structs.NodeService{"db", "db", []string{"slave"}, 8000}); err != nil { + if err := store.EnsureService(21, "foo", &structs.NodeService{"db", "db", []string{"slave"}, 8000}); err != nil { t.Fatalf("err: %v", err) } - if err := store.EnsureService(20, "bar", &structs.NodeService{"db", "db", []string{"master"}, 8000}); err != nil { + if err := store.EnsureService(22, "bar", &structs.NodeService{"db", "db", []string{"master"}, 8000}); err != nil { t.Fatalf("err: %v", err) } - if err := store.EnsureNode(21, structs.Node{"baz", "127.0.0.3"}); err != nil { + if err := store.EnsureNode(23, structs.Node{"baz", "127.0.0.3"}); err != nil { t.Fatalf("err: %v", err) } checkAfter := &structs.HealthCheck{ @@ -741,11 +766,16 @@ func TestStoreSnapshot(t *testing.T) { Status: structs.HealthCritical, ServiceID: "db", } - if err := store.EnsureCheck(22, checkAfter); err != nil { + if err := store.EnsureCheck(24, checkAfter); err != nil { t.Fatalf("err: %v", err) } - if err := store.KVSDelete(23, "/web/b"); err != nil { + if err := store.KVSDelete(25, "/web/b"); err != nil { + t.Fatalf("err: %v", err) + } + + // Nuke an ACL + if err := store.ACLDelete(26, a1.ID); err != nil { t.Fatalf("err: %v", err) } @@ -807,6 +837,15 @@ func TestStoreSnapshot(t *testing.T) { if len(sessions) != 2 { t.Fatalf("missing sessions") } + + // Check for an acl + acls, err = snap.ACLList() + if err != nil { + t.Fatalf("err: %v", err) + } + if len(acls) != 2 { + t.Fatalf("missing acls") + } } func TestEnsureCheck(t *testing.T) { @@ -2117,3 +2156,144 @@ func TestSessionInvalidate_KeyUnlock(t *testing.T) { t.Fatalf("Bad: %v", expires) } } + +func TestACLSet_Get(t *testing.T) { + store, err := testStateStore() + if err != nil { + t.Fatalf("err: %v", err) + } + defer store.Close() + + idx, out, err := store.ACLGet("1234") + if err != nil { + t.Fatalf("err: %v", err) + } + if idx != 0 { + t.Fatalf("bad: %v", idx) + } + if out != nil { + t.Fatalf("bad: %v", out) + } + + a := &structs.ACL{ + Name: "User token", + Type: structs.ACLTypeClient, + Rules: "", + } + if err := store.ACLSet(50, a, false); err != nil { + t.Fatalf("err: %v", err) + } + if a.CreateIndex != 50 { + t.Fatalf("Bad: %v", a) + } + if a.ModifyIndex != 50 { + t.Fatalf("Bad: %v", a) + } + if a.ID == "" { + t.Fatalf("Bad: %v", a) + } + + idx, out, err = store.ACLGet(a.ID) + if err != nil { + t.Fatalf("err: %v", err) + } + if idx != 50 { + t.Fatalf("bad: %v", idx) + } + if !reflect.DeepEqual(out, a) { + t.Fatalf("bad: %v", out) + } + + // Update + a.Rules = "foo bar baz" + if err := store.ACLSet(52, a, false); err != nil { + t.Fatalf("err: %v", err) + } + if a.CreateIndex != 50 { + t.Fatalf("Bad: %v", a) + } + if a.ModifyIndex != 52 { + t.Fatalf("Bad: %v", a) + } + + idx, out, err = store.ACLGet(a.ID) + if err != nil { + t.Fatalf("err: %v", err) + } + if idx != 52 { + t.Fatalf("bad: %v", idx) + } + if !reflect.DeepEqual(out, a) { + t.Fatalf("bad: %v", out) + } +} + +func TestACLDelete(t *testing.T) { + store, err := testStateStore() + if err != nil { + t.Fatalf("err: %v", err) + } + defer store.Close() + + a := &structs.ACL{ + Name: "User token", + Type: structs.ACLTypeClient, + Rules: "", + } + if err := store.ACLSet(50, a, false); err != nil { + t.Fatalf("err: %v", err) + } + + if err := store.ACLDelete(52, a.ID); err != nil { + t.Fatalf("err: %v", err) + } + if err := store.ACLDelete(53, a.ID); err != nil { + t.Fatalf("err: %v", err) + } + + idx, out, err := store.ACLGet(a.ID) + if err != nil { + t.Fatalf("err: %v", err) + } + if idx != 52 { + t.Fatalf("bad: %v", idx) + } + if out != nil { + t.Fatalf("bad: %v", out) + } +} + +func TestACLList(t *testing.T) { + store, err := testStateStore() + if err != nil { + t.Fatalf("err: %v", err) + } + defer store.Close() + + a1 := &structs.ACL{ + Name: "User token", + Type: structs.ACLTypeClient, + } + if err := store.ACLSet(50, a1, false); err != nil { + t.Fatalf("err: %v", err) + } + + a2 := &structs.ACL{ + Name: "User token", + Type: structs.ACLTypeClient, + } + if err := store.ACLSet(51, a2, false); err != nil { + t.Fatalf("err: %v", err) + } + + idx, out, err := store.ACLList() + if err != nil { + t.Fatalf("err: %v", err) + } + if idx != 51 { + t.Fatalf("bad: %v", idx) + } + if len(out) != 2 { + t.Fatalf("bad: %v", out) + } +} diff --git a/consul/structs/structs.go b/consul/structs/structs.go index 56ec95c35..95f273f4f 100644 --- a/consul/structs/structs.go +++ b/consul/structs/structs.go @@ -3,8 +3,10 @@ package structs import ( "bytes" "fmt" - "github.com/ugorji/go/codec" "time" + + "github.com/hashicorp/consul/acl" + "github.com/ugorji/go/codec" ) var ( @@ -20,6 +22,7 @@ const ( DeregisterRequestType KVSRequestType SessionRequestType + ACLRequestType ) const ( @@ -32,6 +35,15 @@ const ( HealthCritical = "critical" ) +const ( + // Client tokens have rules applied + ACLTypeClient = "client" + + // Management tokens have an always allow policy. + // They are used for token management. + ACLTypeManagement = "management" +) + const ( // MaxLockDelay provides a maximum LockDelay value for // a session. Any value above this will not be respected. @@ -43,10 +55,15 @@ type RPCInfo interface { RequestDatacenter() string IsRead() bool AllowStaleRead() bool + ACLToken() string } // QueryOptions is used to specify various flags for read queries type QueryOptions struct { + // Token is the ACL token ID. If not provided, the 'anonymous' + // token is assumed for backwards compatibility. + Token string + // If set, wait until query exceeds given index. Must be provided // with MaxQueryTime. MinQueryIndex uint64 @@ -72,7 +89,15 @@ func (q QueryOptions) AllowStaleRead() bool { return q.AllowStale } -type WriteRequest struct{} +func (q QueryOptions) ACLToken() string { + return q.Token +} + +type WriteRequest struct { + // Token is the ACL token ID. If not provided, the 'anonymous' + // token is assumed for backwards compatibility. + Token string +} // WriteRequest only applies to writes, always false func (w WriteRequest) IsRead() bool { @@ -83,6 +108,10 @@ func (w WriteRequest) AllowStaleRead() bool { return false } +func (w WriteRequest) ACLToken() string { + return w.Token +} + // QueryMeta allows a query response to include potentially // useful metadata about a query type QueryMeta struct { @@ -396,6 +425,74 @@ type IndexedSessions struct { QueryMeta } +// ACL is used to represent a token and it's rules +type ACL struct { + CreateIndex uint64 + ModifyIndex uint64 + ID string + Name string + Type string + Rules string +} +type ACLs []*ACL + +type ACLOp string + +const ( + ACLSet ACLOp = "set" + ACLForceSet = "force-set" + ACLDelete = "delete" +) + +// ACLRequest is used to create, update or delete an ACL +type ACLRequest struct { + Datacenter string + Op ACLOp + ACL ACL + WriteRequest +} + +func (r *ACLRequest) RequestDatacenter() string { + return r.Datacenter +} + +// ACLSpecificRequest is used to request an ACL by ID +type ACLSpecificRequest struct { + Datacenter string + ACL string + QueryOptions +} + +func (r *ACLSpecificRequest) RequestDatacenter() string { + return r.Datacenter +} + +// ACLPolicyRequest is used to request an ACL by ID, conditionally +// filtering on an ID +type ACLPolicyRequest struct { + Datacenter string + ACL string + ETag string + QueryOptions +} + +func (r *ACLPolicyRequest) RequestDatacenter() string { + return r.Datacenter +} + +type IndexedACLs struct { + ACLs ACLs + QueryMeta +} + +type ACLPolicy struct { + ETag string + Parent string + Policy *acl.Policy + TTL time.Duration + QueryMeta +} + // msgpackHandle is a shared handle for encoding/decoding of structs var msgpackHandle = &codec.MsgpackHandle{} diff --git a/website/source/docs/agent/http.html.markdown b/website/source/docs/agent/http.html.markdown index 980dd807f..2b59a2da9 100644 --- a/website/source/docs/agent/http.html.markdown +++ b/website/source/docs/agent/http.html.markdown @@ -17,6 +17,7 @@ All endpoints fall into one of several categories: * catalog - Manages nodes and services * health - Manages health checks * session - Session manipulation +* acl - ACL creations and management * status - Consul system status * internal - Internal APIs. Purposely undocumented, subject to change. @@ -85,6 +86,14 @@ By default, the output of all HTTP API requests return minimized JSON with all whitespace removed. By adding "?pretty" to the HTTP request URL, formatted JSON will be returned. +## ACLs + +Several endpoints in Consul use or require ACL tokens to operate. An agent +can be configured to use a default token in requests using the `acl_token` +configuration option. However, the token can also be specified per-request +by using the "?token=" query parameter. This will take precedence over the +default token. + ## KV The KV endpoint is used to expose a simple key/value store. This can be used @@ -99,7 +108,8 @@ are all supported. It is important to note that each datacenter has its own K/V store, and that there is no replication between datacenters. By default the datacenter of the agent is queried, however the dc can be provided using the "?dc=" query parameter. If a client wants to write -to all Datacenters, one request per datacenter must be made. +to all Datacenters, one request per datacenter must be made. The KV endpoint +supports the use of ACL tokens. ### GET Method @@ -1039,6 +1049,145 @@ It returns a JSON body like this: This endpoint supports blocking queries and all consistency modes. +## ACL + +The ACL endpoints are used to create, update, destroy and query ACL tokens. +The following endpoints are supported: + +* /v1/acl/create: Creates a new token with policy +* /v1/acl/update: Update the policy of a token +* /v1/acl/destroy/\: Destroys a given token +* /v1/acl/info/\: Queries the policy of a given token +* /v1/acl/clone/\: Creates a new token by cloning an existing token +* /v1/acl/list: Lists all the active tokens + +### /v1/acl/create + +The create endpoint is used to make a new token. A token has a name, +type, and a set of ACL rules. The name is opaque to Consul, and type +is either "client" or "management". A management token is effectively +like a root user, and has the ability to perform any action including +creating, modifying, and deleting ACLs. A client token can only perform +actions as permitted by the rules associated, and may never manage ACLs. +This means the request to this endpoint must be made with a management +token. + +In any Consul cluster, only a single datacenter is authoritative for ACLs, so +all requests are automatically routed to that datacenter regardless +of the agent that the request is made to. + +The create endpoint expects a JSON request body to be PUT. The request +body must look like: + + { + "Name": "my-app-token", + "Type": "client", + "Rules": "", + } + +None of the fields are mandatory, and in fact no body needs to be PUT +if the defaults are to be used. The `Name` and `Rules` default to being +blank, and the `Type` defaults to "client". The format of `Rules` is +[documented here](/docs/internals/acl.html). + +The return code is 200 on success, along with a body like: + + {"ID":"adf4238a-882b-9ddc-4a9d-5b6758e4159e"} + +This is used to provide the ID of the newly created ACL token. + +### /v1/acl/update + +The update endpoint is used to modify the policy for a given +ACL token. It is very similar to the create endpoint, however +instead of generating a new token ID, the `ID` field must be +provided. Requests to this endpoint must be made with a management +token. + +In any Consul cluster, only a single datacenter is authoritative for ACLs, so +all requests are automatically routed to that datacenter regardless +of the agent that the request is made to. + +The update endpoint expects a JSON request body to be PUT. The request +body must look like: + + { + "ID": "adf4238a-882b-9ddc-4a9d-5b6758e4159e" + "Name": "my-app-token-updated", + "Type": "client", + "Rules": "# New Rules", + } + +Only the `ID` field is mandatory, the other fields provide defaults. +The `Name` and `Rules` default to being blank, and the `Type` defaults to "client". +The format of `Rules` is [documented here](/docs/internals/acl.html). + +The return code is 200 on success. + +### /v1/acl/destroy/\ + +The destroy endpoint is hit with a PUT and destroys the given ACL token. +The request is automatically routed to the authoritative ACL datacenter. +The token being destroyed must be provided after the slash, and requests +to the endpoint must be made with a management token. + +The return code is 200 on success. + +### /v1/acl/info/\ + +This endpoint is hit with a GET and returns the token information +by ID. All requests are routed to the authoritative ACL datacenter +The token being queried must be provided after the slash. + +It returns a JSON body like this: + + [ + { + "CreateIndex":3, + "ModifyIndex":3, + "ID":"8f246b77-f3e1-ff88-5b48-8ec93abf3e05", + "Name":"Client Token", + "Type":"client", + "Rules":"..." + } + ] + +If the session is not found, null is returned instead of a JSON list. + +### /v1/acl/clone/\ + +The clone endpoint is hit with a PUT and returns a token ID that +is cloned from an existing token. This allows a token to serve +as a template for others, making it simple to generate new tokens +without complex rule management. The source token must be provided +after the slash. Requests to this endpoint require a management token. + +The return code is 200 on success, along with a body like: + + {"ID":"adf4238a-882b-9ddc-4a9d-5b6758e4159e"} + +This is used to provide the ID of the newly created ACL token. + +### /v1/acl/list + +The list endpoint is hit with a GET and lists all the active +ACL tokens. This is a privileged endpoint, and requires a +management token. + +It returns a JSON body like this: + + [ + { + "CreateIndex":3, + "ModifyIndex":3, + "ID":"8f246b77-f3e1-ff88-5b48-8ec93abf3e05", + "Name":"Client Token", + "Type":"client", + "Rules":"..." + }, + ... + ] + ## Status The Status endpoints are used to get information about the status diff --git a/website/source/docs/agent/options.html.markdown b/website/source/docs/agent/options.html.markdown index b436c90f0..4e6d66590 100644 --- a/website/source/docs/agent/options.html.markdown +++ b/website/source/docs/agent/options.html.markdown @@ -284,6 +284,38 @@ definitions support being updated during a reload. will not make use of TLS for outgoing connections. This applies to clients and servers, as both will make outgoing connections. +* `acl_datacenter` - Only used by servers. This designates the datacenter which + is authoritative for ACL information. It must be provided to enable ACLs. + All servers and datacenters must agree on the ACL datacenter. + +* `acl_token` - When provided, the agent will use this token when making requests + to the Consul servers. Clients can override this token on a per-request basis + by providing the ?token parameter. When not provided, the empty token is used + which maps to the 'anonymous' ACL policy. + +* `acl_master_token` - Only used for servers in the `acl_datacenter`. This token + will be created if it does not exist with management level permissions. It allows + operators to bootstrap the ACL system with a token ID that is well-known. + +* `acl_default_policy` - Either "allow" or "deny", defaults to "allow". The + default policy controls the behavior of a token when there is no matching + rule. In "allow" mode, ACLs are a blacklist: any operation not specifically + prohibited is allowed. In "deny" mode, ACLs are a whilelist: any operation not + specifically allowed is blocked. + +* `acl_down_policy` - Either "allow", "deny" or "extend-cache" which is the + default. In the case that the policy for a token cannot be read from the + `acl_datacenter` or leader node, the down policy is applied. In "allow" mode, + all actions are permitted, "deny" restricts all operations, and "extend-cache" + allows any cached ACLs to be used, ignoring their TTL values. If a non-cached + ACL is used, "extend-cache" acts like "deny". + +* `acl_ttl` - Used to control Time-To-Live caching of ACLs. By default this + is 30 seconds. This setting has a major performance impact: reducing it will + cause more frequent refreshes, while increasing it reduces the number of caches. + However, because the caches are not actively invalidated, ACL policy may be stale + up to the TTL value. + ## Ports Used Consul requires up to 5 different ports to work properly, some requiring diff --git a/website/source/docs/internals/acl.html.markdown b/website/source/docs/internals/acl.html.markdown new file mode 100644 index 000000000..166094fa0 --- /dev/null +++ b/website/source/docs/internals/acl.html.markdown @@ -0,0 +1,112 @@ +--- +layout: "docs" +page_title: "ACL System" +sidebar_current: "docs-internals-acl" +--- + +# ACL System + +Consul provides an optional Access Control List (ACL) system which can be used to control +access to data and APIs. The ACL system is a +[Capability-based system](http://en.wikipedia.org/wiki/Capability-based_security) that relies +on tokens which can have fine grained rules applied to them. It is very similar to +[AWS IAM](http://aws.amazon.com/iam/) in many ways. + +## ACL Design + +The ACL system is designed to be easy to use, fast to enforce, flexible to new +policies, all while providing administrative insight. It has been modeled on +the AWS IAM system, as well as the more general object-capability model. The system +is modeled around "tokens". + +Every token has an ID, name, type and rule set. The ID is a randomly generated +UUID, making it unfeasible to guess. The name is opaque and human readable. +Lastly the type is either "client" meaning it cannot modify ACL rules, and +is restricted by the provided rules, or is "management" and is allowed to +perform all actions. + +The token ID is passed along with each RPC request to the servers. Agents +[can be configured](/docs/agent/options.html) with `acl_token` to provide a default token, +but the token can also be specified by a client on a [per-request basis](/docs/agent/http.html). +ACLs are new as of Consul 0.4, meaning versions prior do not provide a token. +This is handled by the special "anonymous" token. Anytime there is no token provided, +the rules defined by that token are automatically applied. This lets policy be enforced +on legacy clients. + +Enforcement is always done by the server nodes. All servers must be [configured +to provide](/docs/agent/options.html) an `acl_datacenter`, which enables +ACL enforcement but also specified the authoritative datacenter. Consul does not +replicate data cross-WAN, and instead relies on [RPC forwarding](/docs/internal/architecture.html) +to support Multi-Datacenter configurations. However, because requests can be +made across datacenter boundaries, ACL tokens must be valid globally. To avoid +replication issues, a single datacenter is considered authoritative and stores +all the tokens. + +When a request is made to any non-authoritative server with a token, it must +be resolved into the appropriate policy. This is done by reading the token +from the authoritative server and caching a configurable `acl_ttl`. The implication +of caching is that the cache TTL is an upper-bound on the staleness of policy +that is enforced. It is possible to set a zero TTL, but this has adverse +performance impacts, as every request requires refreshing the policy. + +Another possible issue is an outage of the `acl_datacenter` or networking +issues preventing access. In this case, it may be impossible for non-authoritative +servers to resolve tokens. Consul provides a number of configurable `acl_down_policy` +choices to tune behavior. It is possible to deny or permit all actions, or to ignore +cache TTLs and enter a fail-safe mode. + +ACLs can also act in either a whilelist or blacklist mode depending +on the configuration of `acl_default_policy`. If the default policy is +to deny all actions, then token rules can be set to allow or whitelist +actions. In the inverse, the allow all default behavior is a blacklist, +where rules are used to prohibit actions. + +Bootstrapping the ACL system is done by providing an initial `acl_master_token` +[configuration](/docs/agent/options.html), which will be created as a +"management" type token if it does not exist. + +## Rule Specification + +A core part of the ACL system is a rule language which is used +to describe the policy that must be enforced. We make use of +the [HashiCorp Configuration Language (HCL)](https://github.com/hashicorp/hcl/) +to specify policy. This language is human readable and interoperable +with JSON making it easy to machine generate. + +As of Consul 0.4, it is only possible to specify policies for the +KV store. Specification in the HCL format looks like: + + # Default all keys to read-only + key "" { + policy = "read" + } + key "foo/" { + policy = "write" + } + key "foo/private/" { + # Deny access to the private dir + policy = "deny" + } + +This is equivalent to the following JSON input: + + { + "key": { + "": { + "policy": "read", + }, + "foo/": { + "policy": "write", + }, + "foo/private": { + "policy": "deny", + } + } + } + +Key policies provide both a prefix and a policy. The rules are enforced +using a longest-prefix match policy. This means we pick the most specific +policy possible. The policy is either "read", "write" or "deny". A "write" +policy implies "read", and there is no way to specify write-only. If there +is no applicable rule, the `acl_default_policy` is applied. + diff --git a/website/source/layouts/docs.erb b/website/source/layouts/docs.erb index 8ade7749c..fcad0930e 100644 --- a/website/source/layouts/docs.erb +++ b/website/source/layouts/docs.erb @@ -38,6 +38,10 @@ Sessions + > + ACLs + + > Security Model