Skip to content

Commit

Permalink
Merge pull request #4982 from hashicorp/f-acls-glob-ns
Browse files Browse the repository at this point in the history
acl: Add support for globbing namespaces
  • Loading branch information
endocrimes committed Dec 19, 2018
2 parents a57dc29 + f10dbbe commit 0ca8b23
Show file tree
Hide file tree
Showing 4 changed files with 281 additions and 10 deletions.
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

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" }`,
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.

```
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

0 comments on commit 0ca8b23

Please sign in to comment.