diff --git a/acl/acl.go b/acl/acl.go index 01f04062a2be..fcb46d230e86 100644 --- a/acl/acl.go +++ b/acl/acl.go @@ -2,8 +2,11 @@ package acl import ( "fmt" + "sort" + "strings" iradix "github.com/hashicorp/go-immutable-radix" + glob "github.com/ryanuber/go-glob" ) // ManagementACL is a singleton used for management tokens @@ -44,6 +47,9 @@ type ACL struct { // namespaces maps a namespace to a capabilitySet namespaces *iradix.Tree + // wildcardNamespaces maps a glob pattern of a namespace to a capabilitySet + wildcardNamespaces map[string]capabilitySet + agent string node string operator string @@ -75,18 +81,33 @@ func NewACL(management bool, policies []*Policy) (*ACL, error) { // Create the ACL object acl := &ACL{} nsTxn := iradix.New().Txn() + wns := make(map[string]capabilitySet) for _, policy := range policies { NAMESPACES: for _, ns := range policy.Namespaces { + // Should the namespace be matched using a glob? + globDefinition := strings.Contains(ns.Name, "*") + // Check for existing capabilities var capabilities capabilitySet - raw, ok := nsTxn.Get([]byte(ns.Name)) - if ok { - capabilities = raw.(capabilitySet) + + if globDefinition { + raw, ok := wns[ns.Name] + if ok { + capabilities = raw + } else { + capabilities = make(capabilitySet) + wns[ns.Name] = capabilities + } } else { - capabilities = make(capabilitySet) - nsTxn.Insert([]byte(ns.Name), capabilities) + 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 @@ -123,6 +144,7 @@ func NewACL(management bool, policies []*Policy) (*ACL, error) { // Finalize the namespaces acl.namespaces = nsTxn.Commit() + acl.wildcardNamespaces = wns return acl, nil } @@ -139,13 +161,12 @@ func (a *ACL) AllowNamespaceOperation(ns string, op string) bool { } // Check for a matching capability set - raw, ok := a.namespaces.Get([]byte(ns)) + capabilities, ok := a.matchingCapabilitySet(ns) if !ok { return false } // Check if the capability has been granted - capabilities := raw.(capabilitySet) return capabilities.Check(op) } @@ -157,13 +178,12 @@ func (a *ACL) AllowNamespace(ns string) bool { } // Check for a matching capability set - raw, ok := a.namespaces.Get([]byte(ns)) + capabilities, ok := a.matchingCapabilitySet(ns) if !ok { return false } // Check if the capability has been granted - capabilities := raw.(capabilitySet) if len(capabilities) == 0 { return false } @@ -171,6 +191,74 @@ func (a *ACL) AllowNamespace(ns string) bool { return !capabilities.Check(PolicyDeny) } +// matchingCapabilitySet looks for a capabilitySet that matches the namespace, +// if no concrete definitions are found, then we return the closest matching +// glob. +// The closest matching glob is the one that has the smallest character +// difference between the namespace and the glob. +func (a *ACL) matchingCapabilitySet(ns string) (capabilitySet, bool) { + // Check for a concrete matching capability set + raw, ok := a.namespaces.Get([]byte(ns)) + if ok { + return raw.(capabilitySet), true + } + + // We didn't find a concrete match, so lets try and evaluate globs. + cs, ok := a.findClosestMatchingGlob(ns) + + return cs, ok +} + +type matchingGlob struct { + ns string + nsLen int + capabilitySet capabilitySet +} + +func (a *ACL) findClosestMatchingGlob(ns string) (capabilitySet, bool) { + // First, find all globs that match. + matchingGlobs := a.findAllMatchingWildcards(ns) + + // If none match, let's return. + if len(matchingGlobs) == 0 { + return capabilitySet{}, false + } + + // If a single matches, lets be efficient and return early. + if len(matchingGlobs) == 1 { + return matchingGlobs[0].capabilitySet, true + } + + nsLen := len(ns) + + // Stable sort the matched globs, based on the character difference between + // the glob definition and the requested namespace. This allows us to be + // more consistent about results based on the policy definition. + sort.SliceStable(matchingGlobs, func(i, j int) bool { + return (matchingGlobs[i].nsLen - nsLen) >= (matchingGlobs[j].nsLen - nsLen) + }) + + return matchingGlobs[0].capabilitySet, true +} + +func (a *ACL) findAllMatchingWildcards(ns string) []matchingGlob { + var matches []matchingGlob + + for k, v := range a.wildcardNamespaces { + isMatch := glob.Glob(string(k), ns) + if isMatch { + pair := matchingGlob{ + ns: k, + nsLen: len(k), + capabilitySet: v, + } + matches = append(matches, pair) + } + } + + return matches +} + // AllowAgentRead checks if read operations are allowed for an agent func (a *ACL) AllowAgentRead() bool { switch { diff --git a/acl/acl_test.go b/acl/acl_test.go index 50fa8b5763da..9a6c5904f8cb 100644 --- a/acl/acl_test.go +++ b/acl/acl_test.go @@ -262,3 +262,96 @@ func TestAllowNamespace(t *testing.T) { }) } } + +func TestWildcardNamespaceMatching(t *testing.T) { + tests := []struct { + Policy string + Allow bool + }{ + { // Wildcard matches + Policy: `namespace "prod-api-*" { policy = "write" }`, + Allow: true, + }, + { // Non globbed namespaces are not wildcards + Policy: `namespace "prod-api" { policy = "write" }`, + Allow: false, + }, + { // Concrete matches take precedence + Policy: `namespace "prod-api-services" { policy = "deny" } + namespace "prod-api-*" { policy = "write" }`, + Allow: false, + }, + { + Policy: `namespace "prod-api-*" { policy = "deny" } + namespace "prod-api-services" { policy = "write" }`, + Allow: true, + }, + { // The closest character match wins + Policy: `namespace "*-api-services" { policy = "deny" } + namespace "prod-api-*" { policy = "write" }`, // 5 vs 8 chars + Allow: false, + }, + } + + for _, tc := range tests { + t.Run(tc.Policy, func(t *testing.T) { + assert := assert.New(t) + + policy, err := Parse(tc.Policy) + assert.NoError(err) + assert.NotNil(policy.Namespaces) + + acl, err := NewACL(false, []*Policy{policy}) + assert.Nil(err) + + assert.Equal(tc.Allow, acl.AllowNamespace("prod-api-services")) + }) + } +} + +func TestACL_matchingCapabilitySet(t *testing.T) { + tests := []struct { + Policy string + NS string + MatchingGlobs []string + }{ + { + Policy: `namespace "production-*" { policy = "write" }`, + NS: "production-api", + MatchingGlobs: []string{"production-*"}, + }, + { + Policy: `namespace "prod-*" { policy = "write" }`, + NS: "production-api", + MatchingGlobs: nil, + }, + { + Policy: `namespace "production-*" { policy = "write" } + namespace "production-*-api" { policy = "deny" }`, + + NS: "production-admin-api", + MatchingGlobs: []string{"production-*", "production-*-api"}, + }, + } + + for _, tc := range tests { + t.Run(tc.Policy, func(t *testing.T) { + assert := assert.New(t) + + policy, err := Parse(tc.Policy) + assert.NoError(err) + assert.NotNil(policy.Namespaces) + + acl, err := NewACL(false, []*Policy{policy}) + assert.Nil(err) + + var namespaces []string + for _, cs := range acl.findAllMatchingWildcards(tc.NS) { + namespaces = append(namespaces, cs.ns) + } + + assert.Equal(tc.MatchingGlobs, namespaces) + }) + } + +} diff --git a/acl/policy.go b/acl/policy.go index 5631f94748f7..db45f11940ec 100644 --- a/acl/policy.go +++ b/acl/policy.go @@ -32,7 +32,7 @@ const ( ) var ( - validNamespace = regexp.MustCompile("^[a-zA-Z0-9-]{1,128}$") + validNamespace = regexp.MustCompile("^[a-zA-Z0-9-*]{1,128}$") ) // Policy represents a parsed HCL or JSON policy.