Skip to content

Commit

Permalink
acl: Add support for globbing namespaces
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
endocrimes committed Dec 11, 2018
1 parent 5306b1e commit 5e0cc4f
Show file tree
Hide file tree
Showing 3 changed files with 146 additions and 10 deletions.
106 changes: 97 additions & 9 deletions acl/acl.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
}

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

Expand All @@ -157,20 +178,87 @@ 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
}

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 {
Expand Down
48 changes: 48 additions & 0 deletions acl/acl_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package acl

import (
"fmt"
"testing"

"github.com/stretchr/testify/assert"
Expand Down Expand Up @@ -262,3 +263,50 @@ 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)
fmt.Printf("Policy: %v, err: %v\n", policy, err)
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"))
})
}
}
2 changes: 1 addition & 1 deletion acl/policy.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down

0 comments on commit 5e0cc4f

Please sign in to comment.