acl: Add support for globbing namespaces

This commit adds basic support for globbing namespaces in acl
definitions.

For concrete definitions, we merge all of the defined policies at load time, and
perform a simple lookup later on. If an exact match of a concrete
definition is found, we do not attempt to resolve globs.

For glob definitions, we merge definitions of exact replicas of a glob.

When loading a policy for a glob defintion, we choose the glob that has
the closest match to the namespace we are resolving for. We define the
closest match as the one with the _smallest character difference_
between the glob and the namespace we are matching.
This commit is contained in:
Danielle Tomlinson
2018-11-08 12:09:00 -08:00
parent 5306b1e953
commit 36d1045e7f
3 changed files with 191 additions and 10 deletions

View File

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

View File

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

View File

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