diff --git a/acl/acl.go b/acl/acl.go new file mode 100644 index 0000000000000000000000000000000000000000..38c75c21da5735b7d797f7bc3ba28aa087faa06a --- /dev/null +++ b/acl/acl.go @@ -0,0 +1,205 @@ +package acl + +import ( + iradix "github.com/hashicorp/go-immutable-radix" +) + +// capabilitySet is a type wrapper to help managing a set of capabilities +type capabilitySet map[string]struct{} + +func (c capabilitySet) Check(k string) bool { + _, ok := c[k] + return ok +} + +func (c capabilitySet) Set(k string) { + c[k] = struct{}{} +} + +func (c capabilitySet) Clear() { + for cap := range c { + delete(c, cap) + } +} + +// ACL object is used to convert a set of policies into a structure that +// can be efficiently evaluated to determine if an action is allowed. +type ACL struct { + // management tokens are allowed to do anything + management bool + + // namespaces maps a namespace to a capabilitySet + namespaces *iradix.Tree + + agent string + node string + operator string +} + +// maxPrivilege returns the policy which grants the most privilege +func maxPrivilege(a, b string) string { + switch { + case a == PolicyDeny || b == PolicyDeny: + return PolicyDeny + case a == PolicyWrite || b == PolicyWrite: + return PolicyWrite + case a == PolicyRead || b == PolicyRead: + return PolicyRead + default: + return "" + } +} + +// NewACL compiles a set of policies into an ACL object +func NewACL(management bool, policies []*Policy) (*ACL, error) { + // Hot-path management tokens + if management { + return &ACL{management: true}, nil + } + + // Create the ACL object + acl := &ACL{} + nsTxn := iradix.New().Txn() + + for _, policy := range policies { + NAMESPACES: + for _, ns := range policy.Namespaces { + // Check for existing capabilities + var capabilities capabilitySet + raw, ok := nsTxn.Get([]byte(ns.Name)) + if ok { + capabilities = raw.(capabilitySet) + } else { + capabilities = make(capabilitySet) + nsTxn.Insert([]byte(ns.Name), capabilities) + } + + // Deny always takes precedence + if capabilities.Check(NamespaceCapabilityDeny) { + continue NAMESPACES + } + + // Add in all the capabilities + for _, cap := range ns.Capabilities { + if cap == NamespaceCapabilityDeny { + // Overwrite any existing capabilities + capabilities.Clear() + capabilities.Set(NamespaceCapabilityDeny) + continue NAMESPACES + } + capabilities.Set(cap) + } + } + + // Take the maximum privilege for agent, node, and operator + if policy.Agent != nil { + acl.agent = maxPrivilege(acl.agent, policy.Agent.Policy) + } + if policy.Node != nil { + acl.node = maxPrivilege(acl.node, policy.Node.Policy) + } + if policy.Operator != nil { + acl.operator = maxPrivilege(acl.operator, policy.Operator.Policy) + } + } + + // Finalize the namespaces + acl.namespaces = nsTxn.Commit() + return acl, nil +} + +// AllowNamespaceOperation checks if a given operation is allowed for a namespace +func (a *ACL) AllowNamespaceOperation(ns string, op string) bool { + // Hot path management tokens + if a.management { + return true + } + + // Check for a matching capability set + raw, ok := a.namespaces.Get([]byte(ns)) + if !ok { + return false + } + + // Check if the capability has been granted + capabilities := raw.(capabilitySet) + return capabilities.Check(op) +} + +// AllowAgentRead checks if read operations are allowed for an agent +func (a *ACL) AllowAgentRead() bool { + switch { + case a.management: + return true + case a.agent == PolicyWrite: + return true + case a.agent == PolicyRead: + return true + default: + return false + } +} + +// AllowAgentWrite checks if write operations are allowed for an agent +func (a *ACL) AllowAgentWrite() bool { + switch { + case a.management: + return true + case a.agent == PolicyWrite: + return true + default: + return false + } +} + +// AllowNodeRead checks if read operations are allowed for a node +func (a *ACL) AllowNodeRead() bool { + switch { + case a.management: + return true + case a.node == PolicyWrite: + return true + case a.node == PolicyRead: + return true + default: + return false + } +} + +// AllowNodeWrite checks if write operations are allowed for a node +func (a *ACL) AllowNodeWrite() bool { + switch { + case a.management: + return true + case a.node == PolicyWrite: + return true + default: + return false + } +} + +// AllowOperatorRead checks if read operations are allowed for a operator +func (a *ACL) AllowOperatorRead() bool { + switch { + case a.management: + return true + case a.operator == PolicyWrite: + return true + case a.operator == PolicyRead: + return true + default: + return false + } +} + +// AllowOperatorWrite checks if write operations are allowed for a operator +func (a *ACL) AllowOperatorWrite() bool { + switch { + case a.management: + return true + case a.operator == PolicyWrite: + return true + default: + return false + } +} diff --git a/acl/acl_test.go b/acl/acl_test.go new file mode 100644 index 0000000000000000000000000000000000000000..32680476901cb693056daa525cfb7c88edce37b3 --- /dev/null +++ b/acl/acl_test.go @@ -0,0 +1,193 @@ +package acl + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestCapabilitySet(t *testing.T) { + var cs capabilitySet = make(map[string]struct{}) + + // Check no capabilities by default + if cs.Check(PolicyDeny) { + t.Fatalf("unexpected check") + } + + // Do a set and check + cs.Set(PolicyDeny) + if !cs.Check(PolicyDeny) { + t.Fatalf("missing check") + } + + // Clear and check + cs.Clear() + if cs.Check(PolicyDeny) { + t.Fatalf("unexpected check") + } +} + +func TestMaxPrivilege(t *testing.T) { + type tcase struct { + Privilege string + PrecedenceOver []string + } + tcases := []tcase{ + { + PolicyDeny, + []string{PolicyDeny, PolicyWrite, PolicyRead, ""}, + }, + { + PolicyWrite, + []string{PolicyWrite, PolicyRead, ""}, + }, + { + PolicyRead, + []string{PolicyRead, ""}, + }, + } + + for idx1, tc := range tcases { + for idx2, po := range tc.PrecedenceOver { + if maxPrivilege(tc.Privilege, po) != tc.Privilege { + t.Fatalf("failed %d %d", idx1, idx2) + } + if maxPrivilege(po, tc.Privilege) != tc.Privilege { + t.Fatalf("failed %d %d", idx1, idx2) + } + } + } +} + +func TestACLManagement(t *testing.T) { + // Create management ACL + acl, err := NewACL(true, nil) + assert.Nil(t, err) + + // Check default namespace rights + assert.Equal(t, true, acl.AllowNamespaceOperation("default", NamespaceCapabilityListJobs)) + assert.Equal(t, true, acl.AllowNamespaceOperation("default", NamespaceCapabilitySubmitJob)) + + // Check non-specified namespace + assert.Equal(t, true, acl.AllowNamespaceOperation("foo", NamespaceCapabilityListJobs)) + + // Check the other simpler operations + assert.Equal(t, true, acl.AllowAgentRead()) + assert.Equal(t, true, acl.AllowAgentWrite()) + assert.Equal(t, true, acl.AllowNodeRead()) + assert.Equal(t, true, acl.AllowNodeWrite()) + assert.Equal(t, true, acl.AllowOperatorRead()) + assert.Equal(t, true, acl.AllowOperatorWrite()) +} + +func TestACLMerge(t *testing.T) { + // Merge read + write policy + p1, err := Parse(readAll) + assert.Nil(t, err) + p2, err := Parse(writeAll) + assert.Nil(t, err) + acl, err := NewACL(false, []*Policy{p1, p2}) + assert.Nil(t, err) + + // Check default namespace rights + assert.Equal(t, true, acl.AllowNamespaceOperation("default", NamespaceCapabilityListJobs)) + assert.Equal(t, true, acl.AllowNamespaceOperation("default", NamespaceCapabilitySubmitJob)) + + // Check non-specified namespace + assert.Equal(t, false, acl.AllowNamespaceOperation("foo", NamespaceCapabilityListJobs)) + + // Check the other simpler operations + assert.Equal(t, true, acl.AllowAgentRead()) + assert.Equal(t, true, acl.AllowAgentWrite()) + assert.Equal(t, true, acl.AllowNodeRead()) + assert.Equal(t, true, acl.AllowNodeWrite()) + assert.Equal(t, true, acl.AllowOperatorRead()) + assert.Equal(t, true, acl.AllowOperatorWrite()) + + // Merge read + blank + p3, err := Parse("") + assert.Nil(t, err) + acl, err = NewACL(false, []*Policy{p1, p3}) + assert.Nil(t, err) + + // Check default namespace rights + assert.Equal(t, true, acl.AllowNamespaceOperation("default", NamespaceCapabilityListJobs)) + assert.Equal(t, false, acl.AllowNamespaceOperation("default", NamespaceCapabilitySubmitJob)) + + // Check non-specified namespace + assert.Equal(t, false, acl.AllowNamespaceOperation("foo", NamespaceCapabilityListJobs)) + + // Check the other simpler operations + assert.Equal(t, true, acl.AllowAgentRead()) + assert.Equal(t, false, acl.AllowAgentWrite()) + assert.Equal(t, true, acl.AllowNodeRead()) + assert.Equal(t, false, acl.AllowNodeWrite()) + assert.Equal(t, true, acl.AllowOperatorRead()) + assert.Equal(t, false, acl.AllowOperatorWrite()) + + // Merge read + deny + p4, err := Parse(denyAll) + assert.Nil(t, err) + acl, err = NewACL(false, []*Policy{p1, p4}) + assert.Nil(t, err) + + // Check default namespace rights + assert.Equal(t, false, acl.AllowNamespaceOperation("default", NamespaceCapabilityListJobs)) + assert.Equal(t, false, acl.AllowNamespaceOperation("default", NamespaceCapabilitySubmitJob)) + + // Check non-specified namespace + assert.Equal(t, false, acl.AllowNamespaceOperation("foo", NamespaceCapabilityListJobs)) + + // Check the other simpler operations + assert.Equal(t, false, acl.AllowAgentRead()) + assert.Equal(t, false, acl.AllowAgentWrite()) + assert.Equal(t, false, acl.AllowNodeRead()) + assert.Equal(t, false, acl.AllowNodeWrite()) + assert.Equal(t, false, acl.AllowOperatorRead()) + assert.Equal(t, false, acl.AllowOperatorWrite()) +} + +var readAll = ` +namespace "default" { + policy = "read" +} +agent { + policy = "read" +} +node { + policy = "read" +} +operator { + policy = "read" +} +` + +var writeAll = ` +namespace "default" { + policy = "write" +} +agent { + policy = "write" +} +node { + policy = "write" +} +operator { + policy = "write" +} +` + +var denyAll = ` +namespace "default" { + policy = "deny" +} +agent { + policy = "deny" +} +node { + policy = "deny" +} +operator { + policy = "deny" +} +`