acl: Add HostVolume ACLs

This adds an initial implementation of ACLs for HostVolumes.

Because HostVolumes are a cluster-wide resource, they cannot be tied to
a namespace, thus here we allow similar wildcard definitions based on
their names, tied to a set of capabilities.

Initially, the only available capabilities are deny, or mount. These
may be extended in the future to allow read-fs, mount-readonly and
similar capabilities.
This commit is contained in:
Danielle Lancashire 2019-05-08 13:14:24 +02:00
parent 7208a7ab88
commit 5f734652f2
No known key found for this signature in database
GPG Key ID: 8D65584EF3DDF91B
4 changed files with 276 additions and 22 deletions

View File

@ -51,6 +51,13 @@ type ACL struct {
// We use an iradix for the purposes of ordered iteration.
wildcardNamespaces *iradix.Tree
// hostVolumes maps a named host volume to a capabilitySet
hostVolumes *iradix.Tree
// wildcardHostVolumes maps a glob pattern of host volume names to a capabilitySet
// We use an iradix for the purposes of ordered iteration.
wildcardHostVolumes *iradix.Tree
agent string
node string
operator string
@ -83,6 +90,8 @@ func NewACL(management bool, policies []*Policy) (*ACL, error) {
acl := &ACL{}
nsTxn := iradix.New().Txn()
wnsTxn := iradix.New().Txn()
hvTxn := iradix.New().Txn()
whvTxn := iradix.New().Txn()
for _, policy := range policies {
NAMESPACES:
@ -128,6 +137,49 @@ func NewACL(management bool, policies []*Policy) (*ACL, error) {
}
}
HOSTVOLUMES:
for _, hv := range policy.HostVolumes {
// Should the volume be matched using a glob?
globDefinition := strings.Contains(hv.Name, "*")
// Check for existing capabilities
var capabilities capabilitySet
if globDefinition {
raw, ok := whvTxn.Get([]byte(hv.Name))
if ok {
capabilities = raw.(capabilitySet)
} else {
capabilities = make(capabilitySet)
whvTxn.Insert([]byte(hv.Name), capabilities)
}
} else {
raw, ok := hvTxn.Get([]byte(hv.Name))
if ok {
capabilities = raw.(capabilitySet)
} else {
capabilities = make(capabilitySet)
hvTxn.Insert([]byte(hv.Name), capabilities)
}
}
// Deny always takes precedence
if capabilities.Check(HostVolumeCapabilityDeny) {
continue
}
// Add in all the capabilities
for _, cap := range hv.Capabilities {
if cap == HostVolumeCapabilityDeny {
// Overwrite any existing capabilities
capabilities.Clear()
capabilities.Set(HostVolumeCapabilityDeny)
continue HOSTVOLUMES
}
capabilities.Set(cap)
}
}
// Take the maximum privilege for agent, node, and operator
if policy.Agent != nil {
acl.agent = maxPrivilege(acl.agent, policy.Agent.Policy)
@ -146,6 +198,9 @@ func NewACL(management bool, policies []*Policy) (*ACL, error) {
// Finalize the namespaces
acl.namespaces = nsTxn.Commit()
acl.wildcardNamespaces = wnsTxn.Commit()
acl.hostVolumes = hvTxn.Commit()
acl.wildcardHostVolumes = whvTxn.Commit()
return acl, nil
}
@ -162,7 +217,7 @@ func (a *ACL) AllowNamespaceOperation(ns string, op string) bool {
}
// Check for a matching capability set
capabilities, ok := a.matchingCapabilitySet(ns)
capabilities, ok := a.matchingNamespaceCapabilitySet(ns)
if !ok {
return false
}
@ -179,7 +234,7 @@ func (a *ACL) AllowNamespace(ns string) bool {
}
// Check for a matching capability set
capabilities, ok := a.matchingCapabilitySet(ns)
capabilities, ok := a.matchingNamespaceCapabilitySet(ns)
if !ok {
return false
}
@ -192,12 +247,50 @@ func (a *ACL) AllowNamespace(ns string) bool {
return !capabilities.Check(PolicyDeny)
}
// matchingCapabilitySet looks for a capabilitySet that matches the namespace,
// AllowHostVolumeOperation checks if a given operation is allowed for a host volume
func (a *ACL) AllowHostVolumeOperation(hv string, op string) bool {
// Hot path management tokens
if a.management {
return true
}
// Check for a matching capability set
capabilities, ok := a.matchingHostVolumeCapabilitySet(hv)
if !ok {
return false
}
// Check if the capability has been granted
return capabilities.Check(op)
}
// AllowHostVolume checks if any operations are allowed for a HostVolume
func (a *ACL) AllowHostVolume(ns string) bool {
// Hot path management tokens
if a.management {
return true
}
// Check for a matching capability set
capabilities, ok := a.matchingHostVolumeCapabilitySet(ns)
if !ok {
return false
}
// Check if the capability has been granted
if len(capabilities) == 0 {
return false
}
return !capabilities.Check(PolicyDeny)
}
// matchingNamespaceCapabilitySet 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) {
func (a *ACL) matchingNamespaceCapabilitySet(ns string) (capabilitySet, bool) {
// Check for a concrete matching capability set
raw, ok := a.namespaces.Get([]byte(ns))
if ok {
@ -205,18 +298,34 @@ func (a *ACL) matchingCapabilitySet(ns string) (capabilitySet, bool) {
}
// We didn't find a concrete match, so lets try and evaluate globs.
return a.findClosestMatchingGlob(ns)
return a.findClosestMatchingGlob(a.wildcardNamespaces, ns)
}
// matchingHostVolumeCapabilitySet looks for a capabilitySet that matches the host volume name,
// 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 volume name and the glob.
func (a *ACL) matchingHostVolumeCapabilitySet(name string) (capabilitySet, bool) {
// Check for a concrete matching capability set
raw, ok := a.hostVolumes.Get([]byte(name))
if ok {
return raw.(capabilitySet), true
}
// We didn't find a concrete match, so lets try and evaluate globs.
return a.findClosestMatchingGlob(a.wildcardHostVolumes, name)
}
type matchingGlob struct {
ns string
name string
difference int
capabilitySet capabilitySet
}
func (a *ACL) findClosestMatchingGlob(ns string) (capabilitySet, bool) {
func (a *ACL) findClosestMatchingGlob(radix *iradix.Tree, ns string) (capabilitySet, bool) {
// First, find all globs that match.
matchingGlobs := a.findAllMatchingWildcards(ns)
matchingGlobs := findAllMatchingWildcards(radix, ns)
// If none match, let's return.
if len(matchingGlobs) == 0 {
@ -238,19 +347,19 @@ func (a *ACL) findClosestMatchingGlob(ns string) (capabilitySet, bool) {
return matchingGlobs[0].capabilitySet, true
}
func (a *ACL) findAllMatchingWildcards(ns string) []matchingGlob {
func findAllMatchingWildcards(radix *iradix.Tree, name string) []matchingGlob {
var matches []matchingGlob
nsLen := len(ns)
nsLen := len(name)
a.wildcardNamespaces.Root().Walk(func(bk []byte, iv interface{}) bool {
radix.Root().Walk(func(bk []byte, iv interface{}) bool {
k := string(bk)
v := iv.(capabilitySet)
isMatch := glob.Glob(k, ns)
isMatch := glob.Glob(k, name)
if isMatch {
pair := matchingGlob{
ns: k,
name: k,
difference: nsLen - len(k) + strings.Count(k, glob.GLOB),
capabilitySet: v,
}

View File

@ -314,6 +314,56 @@ func TestWildcardNamespaceMatching(t *testing.T) {
}
}
func TestWildcardHostVolumeMatching(t *testing.T) {
tests := []struct {
Policy string
Allow bool
}{
{ // Wildcard matches
Policy: `host_volume "prod-api-*" { policy = "write" }`,
Allow: true,
},
{ // Non globbed volumes are not wildcards
Policy: `host_volume "prod-api" { policy = "write" }`,
Allow: false,
},
{ // Concrete matches take precedence
Policy: `host_volume "prod-api-services" { policy = "deny" }
host_volume "prod-api-*" { policy = "write" }`,
Allow: false,
},
{
Policy: `host_volume "prod-api-*" { policy = "deny" }
host_volume "prod-api-services" { policy = "write" }`,
Allow: true,
},
{ // The closest character match wins
Policy: `host_volume "*-api-services" { policy = "deny" }
host_volume "prod-api-*" { policy = "write" }`, // 4 vs 8 chars
Allow: false,
},
{
Policy: `host_volume "prod-api-*" { policy = "write" }
host_volume "*-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.HostVolumes)
acl, err := NewACL(false, []*Policy{policy})
assert.Nil(err)
assert.Equal(tc.Allow, acl.AllowHostVolume("prod-api-services"))
})
}
}
func TestACL_matchingCapabilitySet_returnsAllMatches(t *testing.T) {
tests := []struct {
Policy string
@ -351,8 +401,8 @@ func TestACL_matchingCapabilitySet_returnsAllMatches(t *testing.T) {
assert.Nil(err)
var namespaces []string
for _, cs := range acl.findAllMatchingWildcards(tc.NS) {
namespaces = append(namespaces, cs.ns)
for _, cs := range findAllMatchingWildcards(acl.wildcardNamespaces, tc.NS) {
namespaces = append(namespaces, cs.name)
}
assert.Equal(tc.MatchingGlobs, namespaces)
@ -404,7 +454,7 @@ func TestACL_matchingCapabilitySet_difference(t *testing.T) {
acl, err := NewACL(false, []*Policy{policy})
assert.Nil(err)
matches := acl.findAllMatchingWildcards(tc.NS)
matches := findAllMatchingWildcards(acl.wildcardNamespaces, tc.NS)
assert.Equal(tc.Difference, matches[0].difference)
})
}

View File

@ -21,6 +21,7 @@ const (
// The Policy stanza is a short hand for granting several of these. When capabilities are
// combined we take the union of all capabilities. If the deny capability is present, it
// takes precedence and overwrites all other capabilities.
NamespaceCapabilityDeny = "deny"
NamespaceCapabilityListJobs = "list-jobs"
NamespaceCapabilityReadJob = "read-job"
@ -38,20 +39,36 @@ var (
validNamespace = regexp.MustCompile("^[a-zA-Z0-9-*]{1,128}$")
)
const (
// The following are the fine-grained capabilities that can be granted for a volume set.
// The Policy stanza is a short hand for granting several of these. When capabilities are
// combined we take the union of all capabilities. If the deny capability is present, it
// takes precedence and overwrites all other capabilities.
HostVolumeCapabilityDeny = "deny"
HostVolumeCapabilityMount = "mount"
)
var (
validVolume = regexp.MustCompile("^[a-zA-Z0-9-*]{1,128}$")
)
// Policy represents a parsed HCL or JSON policy.
type Policy struct {
Namespaces []*NamespacePolicy `hcl:"namespace,expand"`
Agent *AgentPolicy `hcl:"agent"`
Node *NodePolicy `hcl:"node"`
Operator *OperatorPolicy `hcl:"operator"`
Quota *QuotaPolicy `hcl:"quota"`
Raw string `hcl:"-"`
Namespaces []*NamespacePolicy `hcl:"namespace,expand"`
HostVolumes []*HostVolumePolicy `hcl:"host_volume,expand"`
Agent *AgentPolicy `hcl:"agent"`
Node *NodePolicy `hcl:"node"`
Operator *OperatorPolicy `hcl:"operator"`
Quota *QuotaPolicy `hcl:"quota"`
Raw string `hcl:"-"`
}
// IsEmpty checks to make sure that at least one policy has been set and is not
// comprised of only a raw policy.
func (p *Policy) IsEmpty() bool {
return len(p.Namespaces) == 0 &&
len(p.HostVolumes) == 0 &&
p.Agent == nil &&
p.Node == nil &&
p.Operator == nil &&
@ -65,6 +82,13 @@ type NamespacePolicy struct {
Capabilities []string
}
// HostVolumePolicy is the policy for a specific named host volume
type HostVolumePolicy struct {
Name string `hcl:",key"`
Policy string
Capabilities []string
}
type AgentPolicy struct {
Policy string
}
@ -134,6 +158,28 @@ func expandNamespacePolicy(policy string) []string {
}
}
func isHostVolumeCapabilityValid(cap string) bool {
switch cap {
case HostVolumeCapabilityDeny, HostVolumeCapabilityMount:
return true
default:
return false
}
}
func expandHostVolumePolicy(policy string) []string {
switch policy {
case PolicyDeny:
return []string{HostVolumeCapabilityDeny}
case PolicyRead:
return []string{HostVolumeCapabilityDeny}
case PolicyWrite:
return []string{HostVolumeCapabilityMount}
default:
return nil
}
}
// Parse is used to parse the specified ACL rules into an
// intermediary set of policies, before being compiled into
// the ACL
@ -178,6 +224,27 @@ func Parse(rules string) (*Policy, error) {
}
}
for _, hv := range p.HostVolumes {
if !validVolume.MatchString(hv.Name) {
return nil, fmt.Errorf("Invalid host volume name: %#v", hv)
}
if hv.Policy != "" && !isPolicyValid(hv.Policy) {
return nil, fmt.Errorf("Invalid host volume policy: %#v", hv)
}
for _, cap := range hv.Capabilities {
if !isHostVolumeCapabilityValid(cap) {
return nil, fmt.Errorf("Invalid host volume capability '%s': %#v", cap, hv)
}
}
// Expand the short hand policy to the capabilities and
// add to any existing capabilities
if hv.Policy != "" {
extraCap := expandHostVolumePolicy(hv.Policy)
hv.Capabilities = append(hv.Capabilities, extraCap...)
}
}
if p.Agent != nil && !isPolicyValid(p.Agent.Policy) {
return nil, fmt.Errorf("Invalid agent policy: %#v", p.Agent)
}

View File

@ -199,6 +199,34 @@ func TestParse(t *testing.T) {
},
},
},
{
`
host_volume "production-tls-*" {
capabilities = ["mount"]
}
`,
"",
&Policy{
HostVolumes: []*HostVolumePolicy{
{
Name: "production-tls-*",
Policy: "",
Capabilities: []string{
HostVolumeCapabilityMount,
},
},
},
},
},
{
`
host_volume "volume has a space" {
capabilities = ["mount"]
}
`,
"Invalid host volume name",
nil,
},
}
for idx, tc := range tcases {