From 45b03913789381c8a5aed355587b453bcc3c09f0 Mon Sep 17 00:00:00 2001 From: Luiz Aoqui Date: Thu, 1 Jun 2023 13:03:20 -0400 Subject: [PATCH] np: implement ACL for node pools (#17365) --- acl/acl.go | 118 +++++- acl/acl_test.go | 376 +++++++++++++++--- acl/policy.go | 78 +++- acl/policy_test.go | 120 ++++++ .../docs/other-specifications/acl-policy.mdx | 56 +++ 5 files changed, 672 insertions(+), 76 deletions(-) diff --git a/acl/acl.go b/acl/acl.go index 5cc62758d..b1f6699e5 100644 --- a/acl/acl.go +++ b/acl/acl.go @@ -50,23 +50,26 @@ type ACL struct { // management tokens are allowed to do anything management bool - // namespaces maps a namespace to a capabilitySet - namespaces *iradix.Tree[capabilitySet] - - // wildcardNamespaces maps a glob pattern of a namespace to a capabilitySet - // We use an iradix for the purposes of ordered iteration. + // The attributes below map polices that have fine-grained capabilities + // with a capabilitySet. + // + // The attributes prefixed with `wildcard` maps the policies for glob + // patterns to a capabilitySet. We use an iradix for the purposes of + // ordered iteration. + namespaces *iradix.Tree[capabilitySet] wildcardNamespaces *iradix.Tree[capabilitySet] - // hostVolumes maps a named host volume to a capabilitySet - hostVolumes *iradix.Tree[capabilitySet] + nodePools *iradix.Tree[capabilitySet] + wildcardNodePools *iradix.Tree[capabilitySet] - // wildcardHostVolumes maps a glob pattern of host volume names to a capabilitySet - // We use an iradix for the purposes of ordered iteration. + hostVolumes *iradix.Tree[capabilitySet] wildcardHostVolumes *iradix.Tree[capabilitySet] variables *iradix.Tree[capabilitySet] wildcardVariables *iradix.Tree[capabilitySet] + // The attributes below store the policy value for policies that don't have + // fine-grained capabilities. agent string node string operator string @@ -102,8 +105,13 @@ func NewACL(management bool, policies []*Policy) (*ACL, error) { acl := &ACL{} nsTxn := iradix.New[capabilitySet]().Txn() wnsTxn := iradix.New[capabilitySet]().Txn() + + npTxn := iradix.New[capabilitySet]().Txn() + wnpTxn := iradix.New[capabilitySet]().Txn() + hvTxn := iradix.New[capabilitySet]().Txn() whvTxn := iradix.New[capabilitySet]().Txn() + svTxn := iradix.New[capabilitySet]().Txn() wsvTxn := iradix.New[capabilitySet]().Txn() @@ -178,6 +186,42 @@ func NewACL(management bool, policies []*Policy) (*ACL, error) { } } + NODEPOOLS: + for _, np := range policy.NodePools { + // Use wildcard transaction if policy name uses glob matching. + txn := npTxn + if strings.Contains(np.Name, "*") { + txn = wnpTxn + } + + // Check for existing capabilities. + var capabilities capabilitySet + + raw, ok := txn.Get([]byte(np.Name)) + if ok { + capabilities = raw + } else { + capabilities = make(capabilitySet) + txn.Insert([]byte(np.Name), capabilities) + } + + // Deny always takes precedence. + if capabilities.Check(NodePoolCapabilityDeny) { + continue NODEPOOLS + } + + // Add in all the capabilities. + for _, cap := range np.Capabilities { + if cap == NodePoolCapabilityDeny { + // Overwrite any existing capabilities. + capabilities.Clear() + capabilities.Set(NodePoolCapabilityDeny) + continue NODEPOOLS + } + capabilities.Set(cap) + } + } + HOSTVOLUMES: for _, hv := range policy.HostVolumes { // Should the volume be matched using a glob? @@ -239,11 +283,16 @@ func NewACL(management bool, policies []*Policy) (*ACL, error) { } } - // Finalize the namespaces + // Finalize policies with capabilities. acl.namespaces = nsTxn.Commit() acl.wildcardNamespaces = wnsTxn.Commit() + + acl.nodePools = npTxn.Commit() + acl.wildcardNodePools = wnpTxn.Commit() + acl.hostVolumes = hvTxn.Commit() acl.wildcardHostVolumes = whvTxn.Commit() + acl.variables = svTxn.Commit() acl.wildcardVariables = wsvTxn.Commit() @@ -323,6 +372,44 @@ func (a *ACL) AllowNamespace(ns string) bool { return !capabilities.Check(PolicyDeny) } +// AllowNodePoolOperation returns true if the given operation is allowed in the +// node pool specified. +func (a *ACL) AllowNodePoolOperation(pool string, op string) bool { + // Hot path if ACL is not enabled or if it's a management token. + if a == nil || a.management { + return true + } + + // Check for matching capability set. + capabilities, ok := a.matchingNodePoolCapabilitySet(pool) + if !ok { + return false + } + + // Check if the capability has been granted. + return capabilities.Check(op) +} + +// AllowNodePool returns true if any operation is allowed for the node pool. +func (a *ACL) AllowNodePool(pool string) bool { + // Hot path if ACL is not enabled or if it's a management token. + if a == nil || a.management { + return true + } + + // Check for matching capability set. + capabilities, ok := a.matchingNodePoolCapabilitySet(pool) + if !ok { + return false + } + + if len(capabilities) == 0 { + return false + } + + return !capabilities.Check(PolicyDeny) +} + // 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 @@ -453,6 +540,17 @@ func (a *ACL) anyNamespaceAllows(cb func(capabilitySet) bool) bool { return allow } +// matchingNodePoolCapabilitySet returns the capabilitySet that closest match +// the node pool. +func (a *ACL) matchingNodePoolCapabilitySet(pool string) (capabilitySet, bool) { + raw, ok := a.nodePools.Get([]byte(pool)) + if ok { + return raw, true + } + + return a.findClosestMatchingGlob(a.wildcardNodePools, pool) +} + // matchingHostVolumeCapabilitySet looks for a capabilitySet that matches the host volume name, // if no concrete definitions are found, then we return the closest matching // glob. diff --git a/acl/acl_test.go b/acl/acl_test.go index aadb99863..aae89918b 100644 --- a/acl/acl_test.go +++ b/acl/acl_test.go @@ -7,6 +7,7 @@ import ( "testing" "github.com/hashicorp/nomad/ci" + "github.com/shoenig/test/must" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -71,119 +72,152 @@ func TestMaxPrivilege(t *testing.T) { func TestACLManagement(t *testing.T) { ci.Parallel(t) - assert := assert.New(t) - // Create management ACL acl, err := NewACL(true, nil) - assert.Nil(err) + must.NoError(t, err) // Check default namespace rights - assert.True(acl.AllowNamespaceOperation("default", NamespaceCapabilityListJobs)) - assert.True(acl.AllowNamespaceOperation("default", NamespaceCapabilitySubmitJob)) - assert.True(acl.AllowNamespace("default")) + must.True(t, acl.AllowNamespaceOperation("default", NamespaceCapabilityListJobs)) + must.True(t, acl.AllowNamespaceOperation("default", NamespaceCapabilitySubmitJob)) + must.True(t, acl.AllowNamespace("default")) // Check non-specified namespace - assert.True(acl.AllowNamespaceOperation("foo", NamespaceCapabilityListJobs)) - assert.True(acl.AllowNamespace("foo")) + must.True(t, acl.AllowNamespaceOperation("foo", NamespaceCapabilityListJobs)) + must.True(t, acl.AllowNamespace("foo")) + + // Check node pool rights. + must.True(t, acl.AllowNodePoolOperation("my-pool", NodePoolCapabilityWrite)) + must.True(t, acl.AllowNodePool("my-pool")) // Check the other simpler operations - assert.True(acl.IsManagement()) - assert.True(acl.AllowAgentRead()) - assert.True(acl.AllowAgentWrite()) - assert.True(acl.AllowNodeRead()) - assert.True(acl.AllowNodeWrite()) - assert.True(acl.AllowOperatorRead()) - assert.True(acl.AllowOperatorWrite()) - assert.True(acl.AllowQuotaRead()) - assert.True(acl.AllowQuotaWrite()) + must.True(t, acl.IsManagement()) + must.True(t, acl.AllowAgentRead()) + must.True(t, acl.AllowAgentWrite()) + must.True(t, acl.AllowNodeRead()) + must.True(t, acl.AllowNodeWrite()) + must.True(t, acl.AllowOperatorRead()) + must.True(t, acl.AllowOperatorWrite()) + must.True(t, acl.AllowQuotaRead()) + must.True(t, acl.AllowQuotaWrite()) } func TestACLMerge(t *testing.T) { ci.Parallel(t) - assert := assert.New(t) - // Merge read + write policy p1, err := Parse(readAll) - assert.Nil(err) + must.NoError(t, err) p2, err := Parse(writeAll) - assert.Nil(err) + must.NoError(t, err) acl, err := NewACL(false, []*Policy{p1, p2}) - assert.Nil(err) + must.NoError(t, err) // Check default namespace rights - assert.True(acl.AllowNamespaceOperation("default", NamespaceCapabilityListJobs)) - assert.True(acl.AllowNamespaceOperation("default", NamespaceCapabilitySubmitJob)) - assert.True(acl.AllowNamespace("default")) + must.True(t, acl.AllowNamespaceOperation("default", NamespaceCapabilityListJobs)) + must.True(t, acl.AllowNamespaceOperation("default", NamespaceCapabilitySubmitJob)) + must.True(t, acl.AllowNamespace("default")) // Check non-specified namespace - assert.False(acl.AllowNamespaceOperation("foo", NamespaceCapabilityListJobs)) - assert.False(acl.AllowNamespace("foo")) + must.False(t, acl.AllowNamespaceOperation("foo", NamespaceCapabilityListJobs)) + must.False(t, acl.AllowNamespace("foo")) + + // Check rights in the node pool specified in policies. + must.True(t, acl.AllowNodePoolOperation("my-pool", NodePoolCapabilityRead)) + must.True(t, acl.AllowNodePoolOperation("my-pool", NodePoolCapabilityWrite)) + must.True(t, acl.AllowNodePool("my-pool")) + + // Check non-specified node pool policies. + must.False(t, acl.AllowNodePoolOperation("other-pool", NodePoolCapabilityRead)) + must.False(t, acl.AllowNodePoolOperation("other-pool", NodePoolCapabilityWrite)) + must.False(t, acl.AllowNodePool("other-pool")) // Check the other simpler operations - assert.False(acl.IsManagement()) - assert.True(acl.AllowAgentRead()) - assert.True(acl.AllowAgentWrite()) - assert.True(acl.AllowNodeRead()) - assert.True(acl.AllowNodeWrite()) - assert.True(acl.AllowOperatorRead()) - assert.True(acl.AllowOperatorWrite()) - assert.True(acl.AllowQuotaRead()) - assert.True(acl.AllowQuotaWrite()) + must.False(t, acl.IsManagement()) + must.True(t, acl.AllowAgentRead()) + must.True(t, acl.AllowAgentWrite()) + must.True(t, acl.AllowNodeRead()) + must.True(t, acl.AllowNodeWrite()) + must.True(t, acl.AllowOperatorRead()) + must.True(t, acl.AllowOperatorWrite()) + must.True(t, acl.AllowQuotaRead()) + must.True(t, acl.AllowQuotaWrite()) // Merge read + blank p3, err := Parse("") - assert.Nil(err) + must.NoError(t, err) acl, err = NewACL(false, []*Policy{p1, p3}) - assert.Nil(err) + must.NoError(t, err) // Check default namespace rights - assert.True(acl.AllowNamespaceOperation("default", NamespaceCapabilityListJobs)) - assert.False(acl.AllowNamespaceOperation("default", NamespaceCapabilitySubmitJob)) + must.True(t, acl.AllowNamespaceOperation("default", NamespaceCapabilityListJobs)) + must.False(t, acl.AllowNamespaceOperation("default", NamespaceCapabilitySubmitJob)) // Check non-specified namespace - assert.False(acl.AllowNamespaceOperation("foo", NamespaceCapabilityListJobs)) + must.False(t, acl.AllowNamespaceOperation("foo", NamespaceCapabilityListJobs)) + + // Check rights in the node pool specified in policies. + must.True(t, acl.AllowNodePoolOperation("my-pool", NodePoolCapabilityRead)) + must.False(t, acl.AllowNodePoolOperation("my-pool", NodePoolCapabilityWrite)) + must.True(t, acl.AllowNodePool("my-pool")) + + // Check non-specified node pool policies. + must.False(t, acl.AllowNodePoolOperation("other-pool", NodePoolCapabilityRead)) + must.False(t, acl.AllowNodePoolOperation("other-pool", NodePoolCapabilityWrite)) + must.False(t, acl.AllowNodePool("other-pool")) // Check the other simpler operations - assert.False(acl.IsManagement()) - assert.True(acl.AllowAgentRead()) - assert.False(acl.AllowAgentWrite()) - assert.True(acl.AllowNodeRead()) - assert.False(acl.AllowNodeWrite()) - assert.True(acl.AllowOperatorRead()) - assert.False(acl.AllowOperatorWrite()) - assert.True(acl.AllowQuotaRead()) - assert.False(acl.AllowQuotaWrite()) + must.False(t, acl.IsManagement()) + must.True(t, acl.AllowAgentRead()) + must.False(t, acl.AllowAgentWrite()) + must.True(t, acl.AllowNodeRead()) + must.False(t, acl.AllowNodeWrite()) + must.True(t, acl.AllowOperatorRead()) + must.False(t, acl.AllowOperatorWrite()) + must.True(t, acl.AllowQuotaRead()) + must.False(t, acl.AllowQuotaWrite()) // Merge read + deny p4, err := Parse(denyAll) - assert.Nil(err) + must.NoError(t, err) acl, err = NewACL(false, []*Policy{p1, p4}) - assert.Nil(err) + must.NoError(t, err) // Check default namespace rights - assert.False(acl.AllowNamespaceOperation("default", NamespaceCapabilityListJobs)) - assert.False(acl.AllowNamespaceOperation("default", NamespaceCapabilitySubmitJob)) + must.False(t, acl.AllowNamespaceOperation("default", NamespaceCapabilityListJobs)) + must.False(t, acl.AllowNamespaceOperation("default", NamespaceCapabilitySubmitJob)) // Check non-specified namespace - assert.False(acl.AllowNamespaceOperation("foo", NamespaceCapabilityListJobs)) + must.False(t, acl.AllowNamespaceOperation("foo", NamespaceCapabilityListJobs)) + + // Check rights in the node pool specified in policies. + must.False(t, acl.AllowNodePoolOperation("my-pool", NodePoolCapabilityRead)) + must.False(t, acl.AllowNodePoolOperation("my-pool", NodePoolCapabilityWrite)) + must.False(t, acl.AllowNodePool("my-pool")) + + // Check non-specified node pool policies. + must.False(t, acl.AllowNodePoolOperation("other-pool", NodePoolCapabilityRead)) + must.False(t, acl.AllowNodePoolOperation("other-pool", NodePoolCapabilityWrite)) + must.False(t, acl.AllowNodePool("other-pool")) // Check the other simpler operations - assert.False(acl.IsManagement()) - assert.False(acl.AllowAgentRead()) - assert.False(acl.AllowAgentWrite()) - assert.False(acl.AllowNodeRead()) - assert.False(acl.AllowNodeWrite()) - assert.False(acl.AllowOperatorRead()) - assert.False(acl.AllowOperatorWrite()) - assert.False(acl.AllowQuotaRead()) - assert.False(acl.AllowQuotaWrite()) + must.False(t, acl.IsManagement()) + must.False(t, acl.AllowAgentRead()) + must.False(t, acl.AllowAgentWrite()) + must.False(t, acl.AllowNodeRead()) + must.False(t, acl.AllowNodeWrite()) + must.False(t, acl.AllowOperatorRead()) + must.False(t, acl.AllowOperatorWrite()) + must.False(t, acl.AllowQuotaRead()) + must.False(t, acl.AllowQuotaWrite()) } var readAll = ` namespace "default" { policy = "read" } +node_pool "my-pool" { + policy = "read" +} agent { policy = "read" } @@ -202,6 +236,9 @@ var writeAll = ` namespace "default" { policy = "write" } +node_pool "my-pool" { + policy = "write" +} agent { policy = "write" } @@ -220,6 +257,9 @@ var denyAll = ` namespace "default" { policy = "deny" } +node_pool "my-pool" { + policy = "deny" +} agent { policy = "deny" } @@ -398,6 +438,214 @@ func TestWildcardNamespaceMatching(t *testing.T) { } } +func TestNodePool(t *testing.T) { + ci.Parallel(t) + + testCases := []struct { + name string + policy string + pool string + allowOps []string + denyOps []string + allow bool + }{ + { + name: "policy read", + policy: ` +node_pool "my-pool" { + policy = "read" +} +`, + pool: "my-pool", + allowOps: []string{NodePoolCapabilityRead}, + denyOps: []string{ + NodePoolCapabilityDelete, + NodePoolCapabilityWrite, + }, + allow: true, + }, + { + name: "policy write", + policy: ` +node_pool "my-pool" { + policy = "write" +} +`, + pool: "my-pool", + allowOps: []string{ + NodePoolCapabilityDelete, + NodePoolCapabilityRead, + NodePoolCapabilityWrite, + }, + denyOps: []string{}, + allow: true, + }, + { + name: "capability write", + policy: ` +node_pool "my-pool" { + capabilities = ["write"] +} +`, + pool: "my-pool", + allowOps: []string{ + NodePoolCapabilityWrite, + }, + denyOps: []string{ + NodePoolCapabilityDelete, + NodePoolCapabilityRead, + }, + allow: true, + }, + { + name: "multiple capabilities", + policy: ` +node_pool "my-pool" { + capabilities = ["read", "delete"] +} +`, + pool: "my-pool", + allowOps: []string{ + NodePoolCapabilityRead, + NodePoolCapabilityDelete, + }, + denyOps: []string{ + NodePoolCapabilityWrite, + }, + allow: true, + }, + { + name: "policy deny takes precedence", + policy: ` +node_pool "my-pool" { + policy = "deny" + capabilities = ["write", "delete"] +} +`, + pool: "my-pool", + allowOps: []string{}, + denyOps: []string{ + NodePoolCapabilityDelete, + NodePoolCapabilityRead, + NodePoolCapabilityWrite, + }, + allow: false, + }, + { + name: "capability deny takes precedence", + policy: ` +node_pool "my-pool" { + capabilities = ["write", "delete", "deny"] +} +`, + pool: "my-pool", + allowOps: []string{}, + denyOps: []string{ + NodePoolCapabilityDelete, + NodePoolCapabilityRead, + NodePoolCapabilityWrite, + }, + allow: false, + }, + { + name: "wildcard matches all", + policy: ` +node_pool "*" { + policy = "read" +} +`, + pool: "my-pool", + allowOps: []string{NodePoolCapabilityRead}, + denyOps: []string{ + NodePoolCapabilityDelete, + NodePoolCapabilityWrite, + }, + allow: true, + }, + { + name: "wildcard matches subset", + policy: ` +node_pool "my-pool-*" { + policy = "read" +} +`, + pool: "my-pool-1", + allowOps: []string{NodePoolCapabilityRead}, + denyOps: []string{ + NodePoolCapabilityDelete, + NodePoolCapabilityWrite, + }, + allow: true, + }, + { + name: "wildcard doesn't match subset", + policy: ` +node_pool "my-pool-*" { + policy = "read" +} +`, + pool: "your-pool-1", + allowOps: []string{}, + denyOps: []string{ + NodePoolCapabilityDelete, + NodePoolCapabilityRead, + NodePoolCapabilityWrite, + }, + allow: false, + }, + { + name: "wildcard matches closest", + policy: ` +node_pool "my-pool-dev-*" { + policy = "read" +} + +node_pool "my-pool-*" { + policy = "write" +} + +node_pool "*" { + policy = "deny" +} +`, + pool: "my-pool-dev-1", + allowOps: []string{NodePoolCapabilityRead}, + denyOps: []string{ + NodePoolCapabilityDelete, + NodePoolCapabilityWrite, + }, + allow: true, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + policy, err := Parse(tc.policy) + must.NoError(t, err) + must.NotNil(t, policy.NodePools) + + acl, err := NewACL(false, []*Policy{policy}) + must.NoError(t, err) + + for _, op := range tc.allowOps { + got := acl.AllowNodePoolOperation(tc.pool, op) + assert.True(t, got, must.Sprintf("expected operation %q to be allowed", op)) + } + + for _, op := range tc.denyOps { + got := acl.AllowNodePoolOperation(tc.pool, op) + assert.False(t, got, must.Sprintf("expected operation %q to be denied", op)) + } + + if tc.allow { + must.True(t, acl.AllowNodePool(tc.pool), must.Sprint("expected node pool to be allowed")) + } else { + must.False(t, acl.AllowNodePool(tc.pool), must.Sprint("expected node pool to be denied")) + } + }) + } +} + func TestWildcardHostVolumeMatching(t *testing.T) { ci.Parallel(t) diff --git a/acl/policy.go b/acl/policy.go index 63d54c827..554c9c462 100644 --- a/acl/policy.go +++ b/acl/policy.go @@ -23,7 +23,7 @@ const ( const ( // The following are the fine-grained capabilities that can be granted within a namespace. - // The Policy block is a short hand for granting several of these. When capabilities are + // The Policy field 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. @@ -55,9 +55,28 @@ var ( validNamespace = regexp.MustCompile("^[a-zA-Z0-9-*]{1,128}$") ) +const ( + // The following are the fine-grained capabilities that can be granted for + // node volume management. + // + // The Policy field 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. + + NodePoolCapabilityDelete = "delete" + NodePoolCapabilityDeny = "deny" + NodePoolCapabilityRead = "read" + NodePoolCapabilityWrite = "write" +) + +var ( + validNodePool = 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 block is a short hand for granting several of these. When capabilities are + // The Policy field 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. @@ -84,6 +103,7 @@ const ( // Policy represents a parsed HCL or JSON policy. type Policy struct { Namespaces []*NamespacePolicy `hcl:"namespace,expand"` + NodePools []*NodePoolPolicy `hcl:"node_pool,expand"` HostVolumes []*HostVolumePolicy `hcl:"host_volume,expand"` Agent *AgentPolicy `hcl:"agent"` Node *NodePolicy `hcl:"node"` @@ -97,6 +117,7 @@ type Policy struct { // comprised of only a raw policy. func (p *Policy) IsEmpty() bool { return len(p.Namespaces) == 0 && + len(p.NodePools) == 0 && len(p.HostVolumes) == 0 && p.Agent == nil && p.Node == nil && @@ -113,6 +134,13 @@ type NamespacePolicy struct { Variables *VariablesPolicy `hcl:"variables"` } +// NodePoolPolicy is the policfy for a specific node pool. +type NodePoolPolicy struct { + Name string `hcl:",key"` + Policy string + Capabilities []string +} + type VariablesPolicy struct { Paths []*VariablesPathPolicy `hcl:"path"` } @@ -247,6 +275,33 @@ func expandNamespacePolicy(policy string) []string { } } +func isNodePoolCapabilityValid(cap string) bool { + switch cap { + case NodePoolCapabilityDelete, NodePoolCapabilityRead, NodePoolCapabilityWrite, + NodePoolCapabilityDeny: + return true + default: + return false + } +} + +func expandNodePoolPolicy(policy string) []string { + switch policy { + case PolicyDeny: + return []string{NodePoolCapabilityDeny} + case PolicyRead: + return []string{NodePoolCapabilityRead} + case PolicyWrite: + return []string{ + NodePoolCapabilityDelete, + NodePoolCapabilityRead, + NodePoolCapabilityWrite, + } + default: + return nil + } +} + func isHostVolumeCapabilityValid(cap string) bool { switch cap { case HostVolumeCapabilityDeny, HostVolumeCapabilityMountReadOnly, HostVolumeCapabilityMountReadWrite: @@ -352,6 +407,25 @@ func Parse(rules string) (*Policy, error) { } + for _, np := range p.NodePools { + if !validNodePool.MatchString(np.Name) { + return nil, fmt.Errorf("Invalid node pool name '%s'", np.Name) + } + if np.Policy != "" && !isPolicyValid(np.Policy) { + return nil, fmt.Errorf("Invalid node pool policy '%s' for '%s'", np.Policy, np.Name) + } + for _, cap := range np.Capabilities { + if !isNodePoolCapabilityValid(cap) { + return nil, fmt.Errorf("Invalid node pool capability '%s' for '%s'", cap, np.Name) + } + } + + if np.Policy != "" { + extraCap := expandNodePoolPolicy(np.Policy) + np.Capabilities = append(np.Capabilities, extraCap...) + } + } + for _, hv := range p.HostVolumes { if !validVolume.MatchString(hv.Name) { return nil, fmt.Errorf("Invalid host volume name: %#v", hv) diff --git a/acl/policy_test.go b/acl/policy_test.go index 31a29de2f..ec6086a79 100644 --- a/acl/policy_test.go +++ b/acl/policy_test.go @@ -296,6 +296,126 @@ func TestParse(t *testing.T) { }, }, }, + { + ` + node_pool "pool-read-only" { + policy = "read" + } + + node_pool "pool-read-write" { + policy = "write" + } + + node_pool "pool-read-upsert" { + policy = "read" + capabilities = ["write"] + } + + node_pool "pool-multiple-capabilities" { + policy = "read" + capabilities = ["write", "delete"] + } + + node_pool "pool-deny-policy" { + policy = "deny" + capabilities = ["write"] + } + + node_pool "pool-deny-capability" { + capabilities = ["deny", "read"] + } + + node_pool "pool-*" { + policy = "read" + } + `, + "", + &Policy{ + NodePools: []*NodePoolPolicy{ + { + Name: "pool-read-only", + Policy: PolicyRead, + Capabilities: []string{ + NodePoolCapabilityRead, + }, + }, + { + Name: "pool-read-write", + Policy: PolicyWrite, + Capabilities: []string{ + NodePoolCapabilityDelete, + NodePoolCapabilityRead, + NodePoolCapabilityWrite, + }, + }, + { + Name: "pool-read-upsert", + Policy: PolicyRead, + Capabilities: []string{ + NodePoolCapabilityWrite, + NodePoolCapabilityRead, + }, + }, + { + Name: "pool-multiple-capabilities", + Policy: PolicyRead, + Capabilities: []string{ + NodePoolCapabilityWrite, + NodePoolCapabilityDelete, + NodePoolCapabilityRead, + }, + }, + { + Name: "pool-deny-policy", + Policy: PolicyDeny, + Capabilities: []string{ + NodePoolCapabilityWrite, + NodePoolCapabilityDeny, + }, + }, + { + Name: "pool-deny-capability", + Policy: "", + Capabilities: []string{ + NodePoolCapabilityDeny, + NodePoolCapabilityRead, + }, + }, + { + Name: "pool-*", + Policy: PolicyRead, + Capabilities: []string{ + NodePoolCapabilityRead, + }, + }, + }, + }, + }, + { + ` + node_pool "" { + } + `, + "Invalid node pool name", + nil, + }, + { + ` + node_pool "pool%" { + } + `, + "Invalid node pool name", + nil, + }, + { + ` + node_pool "my-pool" { + capabilities = ["read", "invalid"] + } + `, + "Invalid node pool capability", + nil, + }, { ` host_volume "production-tls-*" { diff --git a/website/content/docs/other-specifications/acl-policy.mdx b/website/content/docs/other-specifications/acl-policy.mdx index 3a248237b..f59dec3e4 100644 --- a/website/content/docs/other-specifications/acl-policy.mdx +++ b/website/content/docs/other-specifications/acl-policy.mdx @@ -248,6 +248,62 @@ The `policy` field for the node rule can have one of the following values: - `deny`: do not allow the resource to be read or modified. Deny takes precedence when multiple policies are associated with a token. +## Node Pools rules + +Node pool rules are defined with a `node_pool` block. An ACL policy can +include zero, one, or more node pool rules. + + +Node pool rule controls access to the Node Pool API such as create, update, and +list node pools. + +Each node pool rule is labeled with the node pool name it applies to. You may +use wildcard globs (`"*"`) in the label to apply a rule to multiple node pools. + +Similarly to [namespace rules](#namespace-rules) only one `node_pool` rule can +be applied. First an _exact match_ is tried before falling back to a glob-based +lookup, where the rule with the greatest number of matched characters is +chosen. + +Each node pool rule can include a coarse-grained `policy` field and a +fine-grained `capabilities` field. + +The `policy` field for node pool rules can have one of the following values. + +- `read` allows node pools to be listed and read but not modified. +- `write` allows node pools to be read, create, updated, and deleted. +- `deny` forbids node pools to be read or modified. Deny takes precedence when + multiple policies are associated with a token. + +In addition to the coarse-grained `policy`, you can provide a fine-grained list +of `capabilities`. + +- `deny` forbids node pools to be read or modified. Deny takes precedence when + multiple policies are associated with a token. +- `delete` allows node pools to be deleted. +- `read` allows node pools to be listed and read. +- `write` allows node pools to be created and updated. + +The coarse-grained policy permissions are shorthand for the following +fine-grained capabilities. + +| Policy | Capabilities | +| ------- | ------------------------- | +| `deny` | `deny` | +| `read` | `read` | +| `write` | `delete`, `read`, `write` | + +If you provide both a `policy` and `capabilities` list the capabilities are +merged. For example, the policy below adds the `write` capability to the `read` +policy, thus allowing `read` and `write` permission but no `delete`. + +```hcl +node_pool "dev-*" { + policy = "read" + capabilities = ["write"] +} +``` + ## Agent rules The `agent` rule controls access to the [Agent API][api_agent] such as join and