mirror of
https://github.com/kemko/nomad.git
synced 2026-01-01 16:05:42 +03:00
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:
106
acl/acl.go
106
acl/acl.go
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user