Files
nomad/acl/policy_test.go
James Rasell e6a04e06d1 acl: Check for duplicate or invalid keys when writing new policies (#26836)
ACL policies are parsed when creating, updating, or compiling the
resulting ACL object when used. This parsing was silently ignoring
duplicate singleton keys, or invalid keys which does not grant any
additional access, but is a poor UX and can be unexpected.

This change parses all new policy writes and updates, so that
duplicate or invalid keys return an error to the caller. This is
called strict parsing. In order to correctly handle upgrades of
clusters which have existing policies that would fall foul of the
change, a lenient parsing mode is also available. This allows
the policy to continue to be parsed and compiled after an upgrade
without the need for an operator to correct the policy document
prior to further use.

Co-authored-by: Tim Gross <tgross@hashicorp.com>
2025-09-30 08:16:59 +01:00

1102 lines
20 KiB
Go

// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: BUSL-1.1
package acl
import (
"fmt"
"testing"
"github.com/hashicorp/nomad/ci"
"github.com/shoenig/test/must"
)
func TestParse(t *testing.T) {
ci.Parallel(t)
type tcase struct {
Raw string
ExpectErr string
Expect *Policy
}
tcases := []tcase{
{
`
namespace "default" {
policy = "read"
}
`,
"",
&Policy{
Namespaces: []*NamespacePolicy{
{
Name: "default",
Policy: PolicyRead,
Capabilities: []string{
NamespaceCapabilityListJobs,
NamespaceCapabilityParseJob,
NamespaceCapabilityReadJob,
NamespaceCapabilityCSIListVolume,
NamespaceCapabilityCSIReadVolume,
NamespaceCapabilityReadJobScaling,
NamespaceCapabilityListScalingPolicies,
NamespaceCapabilityReadScalingPolicy,
NamespaceCapabilityHostVolumeRead,
},
},
},
},
},
{
`
namespace "default" {
policy = "read"
}
namespace "other" {
policy = "write"
}
namespace "secret" {
capabilities = ["deny", "read-logs"]
}
namespace "apps" {
variables {
path "jobs/write-does-not-imply-read-or-delete" {
capabilities = ["write"]
}
path "project/read-implies-list" {
capabilities = ["read"]
}
path "project/explicit" {
capabilities = ["read", "list", "destroy"]
}
}
}
namespace "autoscaler" {
policy = "scale"
}
host_volume "production-tls-*" {
capabilities = ["mount-readonly"]
}
host_volume "staging-tls-*" {
policy = "write"
}
node_pool "prod" {
capabilities = ["read"]
}
node_pool "dev" {
policy = "write"
}
agent {
policy = "read"
}
node {
policy = "write"
}
operator {
policy = "deny"
}
quota {
policy = "read"
}
plugin {
policy = "read"
}
`,
"",
&Policy{
Namespaces: []*NamespacePolicy{
{
Name: "default",
Policy: PolicyRead,
Capabilities: []string{
NamespaceCapabilityListJobs,
NamespaceCapabilityParseJob,
NamespaceCapabilityReadJob,
NamespaceCapabilityCSIListVolume,
NamespaceCapabilityCSIReadVolume,
NamespaceCapabilityReadJobScaling,
NamespaceCapabilityListScalingPolicies,
NamespaceCapabilityReadScalingPolicy,
NamespaceCapabilityHostVolumeRead,
},
},
{
Name: "other",
Policy: PolicyWrite,
Capabilities: []string{
NamespaceCapabilityListJobs,
NamespaceCapabilityParseJob,
NamespaceCapabilityReadJob,
NamespaceCapabilityCSIListVolume,
NamespaceCapabilityCSIReadVolume,
NamespaceCapabilityReadJobScaling,
NamespaceCapabilityListScalingPolicies,
NamespaceCapabilityReadScalingPolicy,
NamespaceCapabilityHostVolumeRead,
NamespaceCapabilityScaleJob,
NamespaceCapabilitySubmitJob,
NamespaceCapabilityDispatchJob,
NamespaceCapabilityReadLogs,
NamespaceCapabilityReadFS,
NamespaceCapabilityAllocExec,
NamespaceCapabilityAllocLifecycle,
NamespaceCapabilityCSIMountVolume,
NamespaceCapabilityCSIWriteVolume,
NamespaceCapabilitySubmitRecommendation,
NamespaceCapabilityHostVolumeCreate,
NamespaceCapabilityHostVolumeRead,
},
},
{
Name: "secret",
Capabilities: []string{
NamespaceCapabilityDeny,
NamespaceCapabilityReadLogs,
},
},
{
Name: "apps",
Variables: &VariablesPolicy{
Paths: []*VariablesPathPolicy{
{
PathSpec: "jobs/write-does-not-imply-read-or-delete",
Capabilities: []string{VariablesCapabilityWrite},
},
{
PathSpec: "project/read-implies-list",
Capabilities: []string{
VariablesCapabilityRead,
VariablesCapabilityList,
},
},
{
PathSpec: "project/explicit",
Capabilities: []string{
VariablesCapabilityRead,
VariablesCapabilityList,
VariablesCapabilityDestroy,
},
},
},
},
},
{
Name: "autoscaler",
Policy: PolicyScale,
Capabilities: []string{
NamespaceCapabilityListScalingPolicies,
NamespaceCapabilityReadScalingPolicy,
NamespaceCapabilityReadJobScaling,
NamespaceCapabilityScaleJob,
},
},
},
HostVolumes: []*HostVolumePolicy{
{
Name: "production-tls-*",
Capabilities: []string{"mount-readonly"},
},
{
Name: "staging-tls-*",
Policy: "write",
Capabilities: []string{
"mount-readonly",
"mount-readwrite",
},
},
},
NodePools: []*NodePoolPolicy{
{
Name: "prod",
Capabilities: []string{"read"},
},
{
Name: "dev",
Policy: "write",
Capabilities: []string{"delete", "read", "write"},
},
},
Agent: &AgentPolicy{
Policy: PolicyRead,
},
Node: &NodePolicy{
Policy: PolicyWrite,
},
Operator: &OperatorPolicy{
Policy: PolicyDeny,
},
Quota: &QuotaPolicy{
Policy: PolicyRead,
},
Plugin: &PluginPolicy{
Policy: PolicyRead,
},
},
},
{
`
{
"namespace": [
{
"default": {
"policy": "read"
},
},
{
"other": {
"policy": "write"
},
},
{
"secret": {
"capabilities": [
"deny",
"read-logs"
]
}
},
{
"apps": {
"variables": [
{
"path": [
{
"jobs/write-does-not-imply-read-or-delete": {
"capabilities": ["write"],
},
},
{
"project/read-implies-list": {
"capabilities": ["read"],
},
},
{
"project/explicit": {
"capabilities": ["read", "list", "destroy"],
},
},
],
},
],
},
},
{
"autoscaler": {
"policy": "scale"
},
},
],
"host_volume": [
{
"production-tls-*": {
"capabilities": ["mount-readonly"]
}
},
{
"staging-tls-*": {
"policy": "write"
}
}
],
"node_pool": [
{
"prod": {
"capabilities": ["read"]
}
},
{
"dev": {
"policy": "write"
}
}
],
"agent": {
"policy": "read"
},
"node": {
"policy": "write"
},
"operator": {
"policy": "deny"
},
"quota": {
"policy": "read"
},
"plugin": {
"policy": "read"
}
}`,
"",
&Policy{
Namespaces: []*NamespacePolicy{
{
Name: "default",
Policy: PolicyRead,
Capabilities: []string{
NamespaceCapabilityListJobs,
NamespaceCapabilityParseJob,
NamespaceCapabilityReadJob,
NamespaceCapabilityCSIListVolume,
NamespaceCapabilityCSIReadVolume,
NamespaceCapabilityReadJobScaling,
NamespaceCapabilityListScalingPolicies,
NamespaceCapabilityReadScalingPolicy,
NamespaceCapabilityHostVolumeRead,
},
},
{
Name: "other",
Policy: PolicyWrite,
Capabilities: []string{
NamespaceCapabilityListJobs,
NamespaceCapabilityParseJob,
NamespaceCapabilityReadJob,
NamespaceCapabilityCSIListVolume,
NamespaceCapabilityCSIReadVolume,
NamespaceCapabilityReadJobScaling,
NamespaceCapabilityListScalingPolicies,
NamespaceCapabilityReadScalingPolicy,
NamespaceCapabilityHostVolumeRead,
NamespaceCapabilityScaleJob,
NamespaceCapabilitySubmitJob,
NamespaceCapabilityDispatchJob,
NamespaceCapabilityReadLogs,
NamespaceCapabilityReadFS,
NamespaceCapabilityAllocExec,
NamespaceCapabilityAllocLifecycle,
NamespaceCapabilityCSIMountVolume,
NamespaceCapabilityCSIWriteVolume,
NamespaceCapabilitySubmitRecommendation,
NamespaceCapabilityHostVolumeCreate,
NamespaceCapabilityHostVolumeRead,
},
},
{
Name: "secret",
Capabilities: []string{
NamespaceCapabilityDeny,
NamespaceCapabilityReadLogs,
},
},
{
Name: "apps",
Variables: &VariablesPolicy{
Paths: []*VariablesPathPolicy{
{
PathSpec: "jobs/write-does-not-imply-read-or-delete",
Capabilities: []string{VariablesCapabilityWrite},
},
{
PathSpec: "project/read-implies-list",
Capabilities: []string{
VariablesCapabilityRead,
VariablesCapabilityList,
},
},
{
PathSpec: "project/explicit",
Capabilities: []string{
VariablesCapabilityRead,
VariablesCapabilityList,
VariablesCapabilityDestroy,
},
},
},
},
},
{
Name: "autoscaler",
Policy: PolicyScale,
Capabilities: []string{
NamespaceCapabilityListScalingPolicies,
NamespaceCapabilityReadScalingPolicy,
NamespaceCapabilityReadJobScaling,
NamespaceCapabilityScaleJob,
},
},
},
HostVolumes: []*HostVolumePolicy{
{
Name: "production-tls-*",
Capabilities: []string{"mount-readonly"},
},
{
Name: "staging-tls-*",
Policy: "write",
Capabilities: []string{
"mount-readonly",
"mount-readwrite",
},
},
},
NodePools: []*NodePoolPolicy{
{
Name: "prod",
Capabilities: []string{"read"},
},
{
Name: "dev",
Policy: "write",
Capabilities: []string{"delete", "read", "write"},
},
},
Agent: &AgentPolicy{
Policy: PolicyRead,
},
Node: &NodePolicy{
Policy: PolicyWrite,
},
Operator: &OperatorPolicy{
Policy: PolicyDeny,
},
Quota: &QuotaPolicy{
Policy: PolicyRead,
},
Plugin: &PluginPolicy{
Policy: PolicyRead,
},
},
},
{
`
namespace "default" {
policy = "foo"
}
`,
"Invalid namespace policy",
nil,
},
{
`
namespace {
policy = "read"
}
`,
"Invalid namespace name",
nil,
},
{
`
{
"namespace": [
{
"": {
"policy": "read"
}
}
]
}
`,
"Invalid namespace name",
nil,
},
{
`
namespace "dev" {
variables "*" {
capabilities = ["read", "write"]
}
}
`,
"Invalid variable policy: no variable paths in namespace dev",
nil,
},
{
`
namespace "dev" {
variables {
path "/nomad/job" {
capabilities = ["read", "write"]
}
}
}
`,
"Invalid variable path \"/nomad/job\" in namespace dev: cannot start with a leading '/'",
nil,
},
{
`
namespace "dev" {
policy = "read"
variables {
path {}
path "nomad/jobs/example" {
capabilities = ["read"]
}
}
}
`,
"Invalid missing variable path in namespace",
nil,
},
{
`
{
"namespace": [
{
"dev": {
"policy": "read",
"variables": [
{
"paths": [
{
"": {
"capabilities": ["read"]
}
}
]
]
]
}
}
]
}
`,
"no variable paths in namespace dev",
nil,
},
{
`
namespace "default" {
capabilities = ["deny", "foo"]
}
`,
"Invalid namespace capability",
nil,
},
{
`namespace {}`,
"invalid acl policy",
nil,
},
{
`
agent {
policy = "foo"
}
`,
"Invalid agent policy",
nil,
},
{
`
node {
policy = "foo"
}
`,
"Invalid node policy",
nil,
},
{
`
operator {
policy = "foo"
}
`,
"Invalid operator policy",
nil,
},
{
`
quota {
policy = "foo"
}
`,
"Invalid quota policy",
nil,
},
{
`
{
"Name": "my-policy",
"Description": "This is a great policy",
"Rules": "anything"
}
`,
"Invalid policy",
nil,
},
{
`
namespace "has a space"{
policy = "read"
}
`,
"Invalid namespace name",
nil,
},
{
`
namespace "default" {
capabilities = ["sentinel-override"]
}
`,
"",
&Policy{
Namespaces: []*NamespacePolicy{
{
Name: "default",
Policy: "",
Capabilities: []string{
NamespaceCapabilitySentinelOverride,
},
},
},
},
},
{
`
namespace "default" {
capabilities = ["host-volume-register"]
}
namespace "other" {
capabilities = ["host-volume-create"]
}
namespace "foo" {
capabilities = ["host-volume-write"]
}
`,
"",
&Policy{
Namespaces: []*NamespacePolicy{
{
Name: "default",
Policy: "",
Capabilities: []string{
NamespaceCapabilityHostVolumeRegister,
NamespaceCapabilityHostVolumeCreate,
NamespaceCapabilityHostVolumeRead,
},
},
{
Name: "other",
Policy: "",
Capabilities: []string{
NamespaceCapabilityHostVolumeCreate,
NamespaceCapabilityHostVolumeRead,
},
},
{
Name: "foo",
Policy: "",
Capabilities: []string{
NamespaceCapabilityHostVolumeWrite,
NamespaceCapabilityHostVolumeRegister,
NamespaceCapabilityHostVolumeCreate,
NamespaceCapabilityHostVolumeDelete,
NamespaceCapabilityHostVolumeRead,
},
},
},
},
},
{
`
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,
},
{
`
node_pool {
policy = "read"
}
`,
"Invalid node pool name",
nil,
},
{
`
{
"node_pool": [
{
"": {
"policy": "read"
}
}
]
}
`,
"Invalid node pool name",
nil,
},
{
`
host_volume "production-tls-*" {
capabilities = ["mount-readonly"]
}
`,
"",
&Policy{
HostVolumes: []*HostVolumePolicy{
{
Name: "production-tls-*",
Policy: "",
Capabilities: []string{
HostVolumeCapabilityMountReadOnly,
},
},
},
},
},
{
`
host_volume "production-tls-*" {
capabilities = ["mount-readwrite"]
}
`,
"",
&Policy{
HostVolumes: []*HostVolumePolicy{
{
Name: "production-tls-*",
Policy: "",
Capabilities: []string{
HostVolumeCapabilityMountReadWrite,
},
},
},
},
},
{
`
host_volume "volume has a space" {
capabilities = ["mount-readwrite"]
}
`,
"Invalid host volume name",
nil,
},
{
`
host_volume {
policy = "read"
}
`,
"Invalid host volume name",
nil,
},
{
`
{
"host_volume": [
{
"": {
"policy": "read"
}
}
]
}
`,
"Invalid host volume name",
nil,
},
{
`
plugin {
policy = "list"
}
`,
"",
&Policy{
Plugin: &PluginPolicy{
Policy: PolicyList,
},
},
},
{
`
plugin {
policy = "reader"
}
`,
"Invalid plugin policy",
nil,
},
}
for idx, tc := range tcases {
t.Run(fmt.Sprintf("%02d", idx), func(t *testing.T) {
p, err := Parse(tc.Raw, PolicyParseStrict)
if tc.ExpectErr == "" {
must.NoError(t, err)
} else {
must.ErrorContains(t, err, tc.ExpectErr)
}
if tc.Expect != nil {
tc.Expect.Raw = tc.Raw
must.Eq(t, tc.Expect, p)
}
})
}
}
func TestParse_BadInput(t *testing.T) {
ci.Parallel(t)
inputs := []string{
`namespace "\500" {}`,
}
for i, c := range inputs {
t.Run(fmt.Sprintf("%d: %v", i, c), func(t *testing.T) {
_, err := Parse(c, PolicyParseStrict)
must.Error(t, err)
})
}
}
func TestParse_ExtraKeys(t *testing.T) {
ci.Parallel(t)
inputPolicy := `
namespace "foo" {
policy = "write"
variables {
path "project/*" {
capabilities = ["read", "write"]
}
}
}
namespace "bar" {
policy = "write"
variables {
path "system/*" {
capabilities = ["read", "write"]
}
}
}
bogus {
policy = "read"
}
anotherbogus {
policy = "write"
}
node {
policy = "read"
}
node {
policy = "write"
}
agent {
policy = "read"
}
agent {
policy = "write"
}
operator {
policy = "read"
}
operator {
policy = "write"
}
quota {
policy = "read"
}
quota {
policy = "write"
}
host_volume "volume-read-only" {
policy = "read"
}
host_volume "volume-read-write" {
policy = "write"
}
plugin {
policy = "read"
}
plugin {
policy = "write"
}
node_pool "pool-read-only" {
policy = "read"
}
node_pool "pool-read-write" {
policy = "write"
}
`
// Expect an error mentioning all the bad keys when indicating we are
// parsing the policy in strict mode.
_, err := Parse(inputPolicy, PolicyParseStrict)
must.ErrorContains(
t,
err,
"Invalid or duplicate policy keys: agent, anotherbogus, bogus, node, operator, plugin, quota",
)
// Expect no error when parsing in non-strict mode.
_, err = Parse(inputPolicy, PolicyParseLenient)
must.NoError(t, err)
}
func TestPluginPolicy_isValid(t *testing.T) {
ci.Parallel(t)
testCases := []struct {
name string
inputPluginPolicy *PluginPolicy
expectedOutput bool
}{
{
name: "policy deny",
inputPluginPolicy: &PluginPolicy{Policy: "deny"},
expectedOutput: true,
},
{
name: "policy read",
inputPluginPolicy: &PluginPolicy{Policy: "read"},
expectedOutput: true,
},
{
name: "policy list",
inputPluginPolicy: &PluginPolicy{Policy: "list"},
expectedOutput: true,
},
{
name: "policy write",
inputPluginPolicy: &PluginPolicy{Policy: "write"},
expectedOutput: true,
},
{
name: "policy invalid",
inputPluginPolicy: &PluginPolicy{Policy: "invalid"},
expectedOutput: false,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
actualOutput := tc.inputPluginPolicy.isValid()
must.Eq(t, tc.expectedOutput, actualOutput)
})
}
}