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 all 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
111 changes: 102 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,91 @@ 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.
return a.findClosestMatchingGlob(ns)
}

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 {
pair := matchingGlob{
ns: k,
difference: nsLen - len(k) + strings.Count(k, glob.GLOB),
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
148 changes: 148 additions & 0 deletions acl/acl_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -262,3 +262,151 @@ 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" }`, // 4 vs 8 chars
Allow: false,
},
{
Policy: `namespace "prod-api-*" { policy = "write" }
namespace "*-api-services" { policy = "deny" }`, // 4 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_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,
},
{
Policy: `namespace "production-**" { policy = "write" }`,
NS: "production-admin-api",
Difference: 9,
},
{
Policy: `namespace "*" { policy = "write" }`,
NS: "production-admin-api",
Difference: 20,
},
{
Policy: `namespace "*admin*" { policy = "write" }`,
NS: "production-admin-api",
Difference: 15,
},
}

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
30 changes: 30 additions & 0 deletions website/source/guides/security/acl.html.markdown
Original file line number Diff line number Diff line change
Expand Up @@ -253,6 +253,36 @@ namespace "default" {
}
```

Namespaces definitions may also include globs, allowing a single policy definition to apply to a set of namespaces. For example, the below policy allows read access to most production namespaces, but allows write access to the "production-api" namespace, and rejects any access to the "production-web" namespace.

endocrimes marked this conversation as resolved.
Show resolved Hide resolved
```
namespace "production-*" {
policy = "read"
}

namespace "production-api" {
policy = "write"
}

namespace "production-web" {
policy = "deny"
}
```

Namespaces are matched to their policies first by performing a lookup on any _exact match_, before falling back to performing a glob based lookup. When looking up namespaces by glob, the matching policy with the greatest number of matched characters will be chosen. For example:

```
namespace "*-web" {
policy = "deny"
}

namespace "*" {
policy = "write"
}
```

Will evaluate to deny for `production-web`, because it is 9 characters different from the `"*-web"` rule, but 13 characters different from the `"*"` rule.

### Node Rules

The `node` policy controls access to the [Node API](/api/nodes.html) such as listing nodes or triggering a node drain.
Expand Down