Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

acl: Add support for globbing namespaces #4982

Merged
merged 4 commits into from
Dec 19, 2018
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
114 changes: 105 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,10 @@ type ACL struct {
// namespaces maps a namespace to a capabilitySet
namespaces *iradix.Tree

// wildcardNamespaces maps a glob pattern of a namespace to a capabilitySet
// We use an iradix for the purposes of ordered iteration.
wildcardNamespaces *iradix.Tree
endocrimes marked this conversation as resolved.
Show resolved Hide resolved

agent string
node string
operator string
Expand Down Expand Up @@ -75,18 +82,33 @@ func NewACL(management bool, policies []*Policy) (*ACL, error) {
// Create the ACL object
acl := &ACL{}
nsTxn := iradix.New().Txn()
wnsTxn := iradix.New().Txn()

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 := wnsTxn.Get([]byte(ns.Name))
if ok {
capabilities = raw.(capabilitySet)
} else {
capabilities = make(capabilitySet)
wnsTxn.Insert([]byte(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 +145,7 @@ func NewACL(management bool, policies []*Policy) (*ACL, error) {

// Finalize the namespaces
acl.namespaces = nsTxn.Commit()
acl.wildcardNamespaces = wnsTxn.Commit()
return acl, nil
}

Expand All @@ -139,13 +162,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 +179,94 @@ 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)
endocrimes marked this conversation as resolved.
Show resolved Hide resolved

return cs, ok
}

type matchingGlob struct {
ns string
difference 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
}

// 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].difference <= matchingGlobs[j].difference
})

return matchingGlobs[0].capabilitySet, true
}

func (a *ACL) findAllMatchingWildcards(ns string) []matchingGlob {
var matches []matchingGlob

nsLen := len(ns)

a.wildcardNamespaces.Root().Walk(func(bk []byte, iv interface{}) bool {
k := string(bk)
v := iv.(capabilitySet)

isMatch := glob.Glob(k, ns)
if isMatch {
globLen := len(strings.Replace(k, glob.GLOB, "", -1))
pair := matchingGlob{
ns: k,
difference: nsLen - globLen,
endocrimes marked this conversation as resolved.
Show resolved Hide resolved
capabilitySet: v,
}
matches = append(matches, pair)
}

// We always want to walk the entire tree, never terminate early.
return false
})

return matches
}

// AllowAgentRead checks if read operations are allowed for an agent
func (a *ACL) AllowAgentRead() bool {
switch {
Expand Down
128 changes: 128 additions & 0 deletions acl/acl_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -262,3 +262,131 @@ 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
endocrimes marked this conversation as resolved.
Show resolved Hide resolved
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_returnsAllMatches(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)
})
}
}

func TestACL_matchingCapabilitySet_difference(t *testing.T) {
tests := []struct {
Policy string
NS string
Difference int
}{
{
Policy: `namespace "production-*" { policy = "write" }`,
NS: "production-api",
Difference: 3,
},
{
Policy: `namespace "production-*" { policy = "write" }`,
endocrimes marked this conversation as resolved.
Show resolved Hide resolved
NS: "production-admin-api",
Difference: 9,
},
}

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)

matches := acl.findAllMatchingWildcards(tc.NS)
assert.Equal(tc.Difference, matches[0].difference)
})
}

}
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