// Copyright (c) HashiCorp, Inc. // SPDX-License-Identifier: MPL-2.0 package acl import ( "testing" "github.com/hashicorp/nomad/ci" "github.com/shoenig/test/must" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestCapabilitySet(t *testing.T) { ci.Parallel(t) var cs capabilitySet = make(map[string]struct{}) // Check no capabilities by default if cs.Check(PolicyDeny) { t.Fatalf("unexpected check") } // Do a set and check cs.Set(PolicyDeny) if !cs.Check(PolicyDeny) { t.Fatalf("missing check") } // Clear and check cs.Clear() if cs.Check(PolicyDeny) { t.Fatalf("unexpected check") } } func TestMaxPrivilege(t *testing.T) { ci.Parallel(t) type tcase struct { Privilege string PrecedenceOver []string } tcases := []tcase{ { PolicyDeny, []string{PolicyDeny, PolicyWrite, PolicyRead, ""}, }, { PolicyWrite, []string{PolicyWrite, PolicyRead, ""}, }, { PolicyRead, []string{PolicyRead, ""}, }, } for idx1, tc := range tcases { for idx2, po := range tc.PrecedenceOver { if maxPrivilege(tc.Privilege, po) != tc.Privilege { t.Fatalf("failed %d %d", idx1, idx2) } if maxPrivilege(po, tc.Privilege) != tc.Privilege { t.Fatalf("failed %d %d", idx1, idx2) } } } } func TestACLManagement(t *testing.T) { ci.Parallel(t) // Create management ACL acl, err := NewACL(true, nil) must.NoError(t, err) // Check default namespace rights must.True(t, acl.AllowNamespaceOperation("default", NamespaceCapabilityListJobs)) must.True(t, acl.AllowNamespaceOperation("default", NamespaceCapabilitySubmitJob)) must.True(t, acl.AllowNamespace("default")) // Check non-specified namespace 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 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) // Merge read + write policy p1, err := Parse(readAll) must.NoError(t, err) p2, err := Parse(writeAll) must.NoError(t, err) acl, err := NewACL(false, []*Policy{p1, p2}) must.NoError(t, err) // Check default namespace rights must.True(t, acl.AllowNamespaceOperation("default", NamespaceCapabilityListJobs)) must.True(t, acl.AllowNamespaceOperation("default", NamespaceCapabilitySubmitJob)) must.True(t, acl.AllowNamespace("default")) // Check non-specified namespace 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 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("") must.NoError(t, err) acl, err = NewACL(false, []*Policy{p1, p3}) must.NoError(t, err) // Check default namespace rights must.True(t, acl.AllowNamespaceOperation("default", NamespaceCapabilityListJobs)) must.False(t, acl.AllowNamespaceOperation("default", NamespaceCapabilitySubmitJob)) // Check non-specified namespace 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 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) must.NoError(t, err) acl, err = NewACL(false, []*Policy{p1, p4}) must.NoError(t, err) // Check default namespace rights must.False(t, acl.AllowNamespaceOperation("default", NamespaceCapabilityListJobs)) must.False(t, acl.AllowNamespaceOperation("default", NamespaceCapabilitySubmitJob)) // Check non-specified namespace 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 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" } node { policy = "read" } operator { policy = "read" } quota { policy = "read" } ` var writeAll = ` namespace "default" { policy = "write" } node_pool "my-pool" { policy = "write" } agent { policy = "write" } node { policy = "write" } operator { policy = "write" } quota { policy = "write" } ` var denyAll = ` namespace "default" { policy = "deny" } node_pool "my-pool" { policy = "deny" } agent { policy = "deny" } node { policy = "deny" } operator { policy = "deny" } quota { policy = "deny" } ` func TestAllowNamespace(t *testing.T) { ci.Parallel(t) tests := []struct { name string policy string allow bool namespace string }{ { name: "foo namespace - no capabilities", policy: `namespace "foo" {}`, allow: false, namespace: "foo", }, { name: "foo namespace - deny policy", policy: `namespace "foo" { policy = "deny" }`, allow: false, namespace: "foo", }, { name: "foo namespace - deny capability", policy: `namespace "foo" { capabilities = ["deny"] }`, allow: false, namespace: "foo", }, { name: "foo namespace - with capability", policy: `namespace "foo" { capabilities = ["list-jobs"] }`, allow: true, namespace: "foo", }, { name: "foo namespace - with policy", policy: `namespace "foo" { policy = "read" }`, allow: true, namespace: "foo", }, { name: "wildcard namespace - no capabilities", policy: `namespace "foo" {}`, allow: false, namespace: "*", }, { name: "wildcard namespace - deny policy", policy: `namespace "foo" { policy = "deny" }`, allow: false, namespace: "*", }, { name: "wildcard namespace - deny capability", policy: `namespace "foo" { capabilities = ["deny"] }`, allow: false, namespace: "*", }, { name: "wildcard namespace - with capability", policy: `namespace "foo" { capabilities = ["list-jobs"] }`, allow: true, namespace: "*", }, { name: "wildcard namespace - with policy", policy: `namespace "foo" { policy = "read" }`, allow: true, namespace: "*", }, { name: "wildcard namespace - no namespace rule", policy: `agent { policy = "read" }`, allow: false, namespace: "*", }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { policy, err := Parse(tc.policy) require.NoError(t, err) acl, err := NewACL(false, []*Policy{policy}) require.NoError(t, err) got := acl.AllowNamespace(tc.namespace) require.Equal(t, tc.allow, got) }) } } func TestWildcardNamespaceMatching(t *testing.T) { ci.Parallel(t) tests := []struct { name string policy string allow bool namespace string }{ { name: "wildcard matches", policy: `namespace "prod-api-*" { policy = "write" }`, allow: true, namespace: "prod-api-services", }, { name: "non globbed namespaces are not wildcards", policy: `namespace "prod-api" { policy = "write" }`, allow: false, namespace: "prod-api-services", }, { name: "concrete matches take precedence", policy: `namespace "prod-api-services" { policy = "deny" } namespace "prod-api-*" { policy = "write" }`, allow: false, namespace: "prod-api-services", }, { name: "glob match", policy: `namespace "prod-api-*" { policy = "deny" } namespace "prod-api-services" { policy = "write" }`, allow: true, namespace: "prod-api-services", }, { name: "closest character match wins - suffix", policy: `namespace "*-api-services" { policy = "deny" } namespace "prod-api-*" { policy = "write" }`, // 4 vs 8 chars allow: false, namespace: "prod-api-services", }, { name: "closest character match wins - prefix", policy: `namespace "prod-api-*" { policy = "write" } namespace "*-api-services" { policy = "deny" }`, // 4 vs 8 chars allow: false, namespace: "prod-api-services", }, { name: "wildcard namespace with glob match", policy: `namespace "prod-api-*" { policy = "deny" } namespace "prod-api-services" { policy = "write" }`, allow: true, namespace: "*", }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { policy, err := Parse(tc.policy) require.NoError(t, err) require.NotNil(t, policy.Namespaces) acl, err := NewACL(false, []*Policy{policy}) require.NoError(t, err) got := acl.AllowNamespace(tc.namespace) require.Equal(t, tc.allow, got) }) } } 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) 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 TestVariablesMatching(t *testing.T) { ci.Parallel(t) tests := []struct { name string policy string ns string path string op string claim *ACLClaim allow bool }{ { name: "concrete namespace with concrete path matches", policy: `namespace "ns" { variables { path "foo/bar" { capabilities = ["read"] }}}`, ns: "ns", path: "foo/bar", op: "read", allow: true, }, { name: "concrete namespace with concrete path matches for expanded caps", policy: `namespace "ns" { variables { path "foo/bar" { capabilities = ["read"] }}}`, ns: "ns", path: "foo/bar", op: "list", allow: true, }, { name: "concrete namespace with wildcard path matches", policy: `namespace "ns" { variables { path "foo/*" { capabilities = ["read"] }}}`, ns: "ns", path: "foo/bar", op: "read", allow: true, }, { name: "concrete namespace with non-prefix wildcard path matches", policy: `namespace "ns" { variables { path "*/bar" { capabilities = ["read"] }}}`, ns: "ns", path: "foo/bar", op: "read", allow: true, }, { name: "concrete namespace with overlapping wildcard path prefix over suffix matches", policy: `namespace "ns" { variables { path "*/bar" { capabilities = ["list"] } path "foo/*" { capabilities = ["write"] } }}`, ns: "ns", path: "foo/bar", op: "write", allow: true, }, { name: "concrete namespace with overlapping wildcard path prefix over suffix denied", policy: `namespace "ns" { variables { path "*/bar" { capabilities = ["list"] } path "foo/*" { capabilities = ["write"] } }}`, ns: "ns", path: "foo/bar", op: "list", allow: false, }, { name: "concrete namespace with wildcard path matches most specific only", policy: `namespace "ns" { variables { path "*" { capabilities = ["read"] } path "foo/*" { capabilities = ["read"] } path "foo/bar" { capabilities = ["list"] } }}`, ns: "ns", path: "foo/bar", op: "read", allow: false, }, { name: "concrete namespace with invalid concrete path fails", policy: `namespace "ns" { variables { path "bar" { capabilities = ["read"] }}}`, ns: "ns", path: "foo/bar", op: "read", allow: false, }, { name: "concrete namespace with invalid wildcard path fails", policy: `namespace "ns" { variables { path "*/foo" { capabilities = ["read"] }}}`, ns: "ns", path: "foo/bar", op: "read", allow: false, }, { name: "wildcard namespace with concrete path matches", policy: `namespace "*" { variables { path "foo/bar" { capabilities = ["read"] }}}`, ns: "ns", path: "foo/bar", op: "read", allow: true, }, { name: "wildcard namespace with invalid concrete path fails", policy: `namespace "*" { variables { path "bar" { capabilities = ["read"] }}}`, ns: "ns", path: "foo/bar", op: "read", allow: false, }, { name: "wildcard in user provided path fails", policy: `namespace "ns" { variables { path "foo/bar" { capabilities = ["read"] }}}`, ns: "ns", path: "*", op: "read", allow: false, }, { name: "wildcard attempt to bypass delimiter null byte fails", policy: `namespace "ns" { variables { path "foo/bar" { capabilities = ["read"] }}}`, ns: "ns*", path: "bar", op: "read", allow: false, }, { name: "wildcard with more specific denied path", policy: `namespace "ns" { variables { path "*" { capabilities = ["list"] } path "system/*" { capabilities = ["deny"] }}}`, ns: "ns", path: "system/not-allowed", op: "list", allow: false, }, { name: "multiple namespace with overlapping paths", policy: `namespace "ns" { variables { path "*" { capabilities = ["list"] } path "system/*" { capabilities = ["deny"] }}} namespace "prod" { variables { path "*" { capabilities = ["list"]}}}`, ns: "prod", path: "system/is-allowed", op: "list", allow: true, }, { name: "claim with more specific policy", policy: `namespace "ns" { variables { path "nomad/jobs/example" { capabilities = ["deny"] }}}`, ns: "ns", path: "nomad/jobs/example", op: "read", claim: &ACLClaim{Namespace: "ns", Job: "example", Group: "foo", Task: "bar"}, allow: false, }, { name: "claim with less specific policy", policy: `namespace "ns" { variables { path "nomad/jobs" { capabilities = ["deny"] }}}`, ns: "ns", path: "nomad/jobs/example", op: "read", claim: &ACLClaim{Namespace: "ns", Job: "example", Group: "foo", Task: "bar"}, allow: true, }, { name: "claim with less specific wildcard policy", policy: `namespace "ns" { variables { path "nomad/jobs/*" { capabilities = ["deny"] }}}`, ns: "ns", path: "nomad/jobs/example", op: "read", claim: &ACLClaim{Namespace: "ns", Job: "example", Group: "foo", Task: "bar"}, allow: true, }, } for _, tc := range tests { tc := tc t.Run(tc.name, func(t *testing.T) { policy, err := Parse(tc.policy) require.NoError(t, err) require.NotNil(t, policy.Namespaces[0].Variables) acl, err := NewACL(false, []*Policy{policy}) require.NoError(t, err) allowed := acl.AllowVariableOperation(tc.ns, tc.path, tc.op, tc.claim) require.Equal(t, tc.allow, allowed) }) } t.Run("search over namespace", func(t *testing.T) { policy, err := Parse(`namespace "ns" { variables { path "foo/bar" { capabilities = ["read"] }}}`) require.NoError(t, err) require.NotNil(t, policy.Namespaces[0].Variables) acl, err := NewACL(false, []*Policy{policy}) require.NoError(t, err) require.True(t, acl.AllowVariableSearch("ns")) require.False(t, acl.AllowVariableSearch("no-access")) }) } func TestACL_matchingCapabilitySet_returnsAllMatches(t *testing.T) { ci.Parallel(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 findAllMatchingWildcards(acl.wildcardNamespaces, tc.NS) { namespaces = append(namespaces, cs.name) } assert.Equal(tc.MatchingGlobs, namespaces) }) } } func TestACL_matchingCapabilitySet_difference(t *testing.T) { ci.Parallel(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 := findAllMatchingWildcards(acl.wildcardNamespaces, tc.NS) assert.Equal(tc.Difference, matches[0].difference) }) } }