open-nomad/acl/policy.go

552 lines
16 KiB
Go

// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: MPL-2.0
package acl
import (
"errors"
"fmt"
"regexp"
"github.com/hashicorp/hcl"
"github.com/hashicorp/hcl/hcl/ast"
)
const (
// The following levels are the only valid values for the `policy = "read"` block.
// When policies are merged together, the most privilege is granted, except for deny
// which always takes precedence and supersedes.
PolicyDeny = "deny"
PolicyRead = "read"
PolicyList = "list"
PolicyWrite = "write"
PolicyScale = "scale"
)
const (
// The following are the fine-grained capabilities that can be granted within a namespace.
// 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.
NamespaceCapabilityDeny = "deny"
NamespaceCapabilityListJobs = "list-jobs"
NamespaceCapabilityParseJob = "parse-job"
NamespaceCapabilityReadJob = "read-job"
NamespaceCapabilitySubmitJob = "submit-job"
NamespaceCapabilityDispatchJob = "dispatch-job"
NamespaceCapabilityReadLogs = "read-logs"
NamespaceCapabilityReadFS = "read-fs"
NamespaceCapabilityAllocExec = "alloc-exec"
NamespaceCapabilityAllocNodeExec = "alloc-node-exec"
NamespaceCapabilityAllocLifecycle = "alloc-lifecycle"
NamespaceCapabilitySentinelOverride = "sentinel-override"
NamespaceCapabilityCSIRegisterPlugin = "csi-register-plugin"
NamespaceCapabilityCSIWriteVolume = "csi-write-volume"
NamespaceCapabilityCSIReadVolume = "csi-read-volume"
NamespaceCapabilityCSIListVolume = "csi-list-volume"
NamespaceCapabilityCSIMountVolume = "csi-mount-volume"
NamespaceCapabilityListScalingPolicies = "list-scaling-policies"
NamespaceCapabilityReadScalingPolicy = "read-scaling-policy"
NamespaceCapabilityReadJobScaling = "read-job-scaling"
NamespaceCapabilityScaleJob = "scale-job"
NamespaceCapabilitySubmitRecommendation = "submit-recommendation"
)
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 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.
HostVolumeCapabilityDeny = "deny"
HostVolumeCapabilityMountReadOnly = "mount-readonly"
HostVolumeCapabilityMountReadWrite = "mount-readwrite"
)
var (
validVolume = regexp.MustCompile("^[a-zA-Z0-9-*]{1,128}$")
)
const (
// The following are the fine-grained capabilities that can be
// granted for a variables path. When capabilities are
// combined we take the union of all capabilities.
VariablesCapabilityList = "list"
VariablesCapabilityRead = "read"
VariablesCapabilityWrite = "write"
VariablesCapabilityDestroy = "destroy"
VariablesCapabilityDeny = "deny"
)
// 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"`
Operator *OperatorPolicy `hcl:"operator"`
Quota *QuotaPolicy `hcl:"quota"`
Plugin *PluginPolicy `hcl:"plugin"`
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.NodePools) == 0 &&
len(p.HostVolumes) == 0 &&
p.Agent == nil &&
p.Node == nil &&
p.Operator == nil &&
p.Quota == nil &&
p.Plugin == nil
}
// NamespacePolicy is the policy for a specific namespace
type NamespacePolicy struct {
Name string `hcl:",key"`
Policy string
Capabilities []string
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"`
}
type VariablesPathPolicy struct {
PathSpec string `hcl:",key"`
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
}
type NodePolicy struct {
Policy string
}
type OperatorPolicy struct {
Policy string
}
type QuotaPolicy struct {
Policy string
}
type PluginPolicy struct {
Policy string
}
// isPolicyValid makes sure the given string matches one of the valid policies.
func isPolicyValid(policy string) bool {
switch policy {
case PolicyDeny, PolicyRead, PolicyWrite, PolicyScale:
return true
default:
return false
}
}
func (p *PluginPolicy) isValid() bool {
switch p.Policy {
case PolicyDeny, PolicyRead, PolicyList:
return true
default:
return false
}
}
// isNamespaceCapabilityValid ensures the given capability is valid for a namespace policy
func isNamespaceCapabilityValid(cap string) bool {
switch cap {
case NamespaceCapabilityDeny, NamespaceCapabilityParseJob, NamespaceCapabilityListJobs, NamespaceCapabilityReadJob,
NamespaceCapabilitySubmitJob, NamespaceCapabilityDispatchJob, NamespaceCapabilityReadLogs,
NamespaceCapabilityReadFS, NamespaceCapabilityAllocLifecycle,
NamespaceCapabilityAllocExec, NamespaceCapabilityAllocNodeExec,
NamespaceCapabilityCSIReadVolume, NamespaceCapabilityCSIWriteVolume, NamespaceCapabilityCSIListVolume, NamespaceCapabilityCSIMountVolume, NamespaceCapabilityCSIRegisterPlugin,
NamespaceCapabilityListScalingPolicies, NamespaceCapabilityReadScalingPolicy, NamespaceCapabilityReadJobScaling, NamespaceCapabilityScaleJob:
return true
// Separate the enterprise-only capabilities
case NamespaceCapabilitySentinelOverride, NamespaceCapabilitySubmitRecommendation:
return true
default:
return false
}
}
// isPathCapabilityValid ensures the given capability is valid for a
// variables path policy
func isPathCapabilityValid(cap string) bool {
switch cap {
case VariablesCapabilityWrite, VariablesCapabilityRead,
VariablesCapabilityList, VariablesCapabilityDestroy, VariablesCapabilityDeny:
return true
default:
return false
}
}
// expandNamespacePolicy provides the equivalent set of capabilities for
// a namespace policy
func expandNamespacePolicy(policy string) []string {
read := []string{
NamespaceCapabilityListJobs,
NamespaceCapabilityParseJob,
NamespaceCapabilityReadJob,
NamespaceCapabilityCSIListVolume,
NamespaceCapabilityCSIReadVolume,
NamespaceCapabilityReadJobScaling,
NamespaceCapabilityListScalingPolicies,
NamespaceCapabilityReadScalingPolicy,
}
write := make([]string, len(read))
copy(write, read)
write = append(write, []string{
NamespaceCapabilityScaleJob,
NamespaceCapabilitySubmitJob,
NamespaceCapabilityDispatchJob,
NamespaceCapabilityReadLogs,
NamespaceCapabilityReadFS,
NamespaceCapabilityAllocExec,
NamespaceCapabilityAllocLifecycle,
NamespaceCapabilityCSIMountVolume,
NamespaceCapabilityCSIWriteVolume,
NamespaceCapabilitySubmitRecommendation,
}...)
switch policy {
case PolicyDeny:
return []string{NamespaceCapabilityDeny}
case PolicyRead:
return read
case PolicyWrite:
return write
case PolicyScale:
return []string{
NamespaceCapabilityListScalingPolicies,
NamespaceCapabilityReadScalingPolicy,
NamespaceCapabilityReadJobScaling,
NamespaceCapabilityScaleJob,
}
default:
return nil
}
}
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:
return true
default:
return false
}
}
func expandHostVolumePolicy(policy string) []string {
switch policy {
case PolicyDeny:
return []string{HostVolumeCapabilityDeny}
case PolicyRead:
return []string{HostVolumeCapabilityMountReadOnly}
case PolicyWrite:
return []string{HostVolumeCapabilityMountReadOnly, HostVolumeCapabilityMountReadWrite}
default:
return nil
}
}
func expandVariablesCapabilities(caps []string) []string {
var foundRead, foundList bool
for _, cap := range caps {
switch cap {
case VariablesCapabilityDeny:
return []string{VariablesCapabilityDeny}
case VariablesCapabilityRead:
foundRead = true
case VariablesCapabilityList:
foundList = true
}
}
if foundRead && !foundList {
caps = append(caps, PolicyList)
}
return caps
}
// Parse is used to parse the specified ACL rules into an
// intermediary set of policies, before being compiled into
// the ACL
func Parse(rules string) (*Policy, error) {
// Decode the rules
p := &Policy{Raw: rules}
if rules == "" {
// Hot path for empty rules
return p, nil
}
// Attempt to parse
if err := hclDecode(p, rules); err != nil {
return nil, fmt.Errorf("Failed to parse ACL Policy: %v", err)
}
// At least one valid policy must be specified, we don't want to store only
// raw data
if p.IsEmpty() {
return nil, fmt.Errorf("Invalid policy: %s", p.Raw)
}
// Validate the policy
for _, ns := range p.Namespaces {
if !validNamespace.MatchString(ns.Name) {
return nil, fmt.Errorf("Invalid namespace name: %#v", ns)
}
if ns.Policy != "" && !isPolicyValid(ns.Policy) {
return nil, fmt.Errorf("Invalid namespace policy: %#v", ns)
}
for _, cap := range ns.Capabilities {
if !isNamespaceCapabilityValid(cap) {
return nil, fmt.Errorf("Invalid namespace capability '%s': %#v", cap, ns)
}
}
// Expand the short hand policy to the capabilities and
// add to any existing capabilities
if ns.Policy != "" {
extraCap := expandNamespacePolicy(ns.Policy)
ns.Capabilities = append(ns.Capabilities, extraCap...)
}
if ns.Variables != nil {
if len(ns.Variables.Paths) == 0 {
return nil, fmt.Errorf("Invalid variable policy: no variable paths in namespace %s", ns.Name)
}
for _, pathPolicy := range ns.Variables.Paths {
if pathPolicy.PathSpec == "" {
return nil, fmt.Errorf("Invalid missing variable path in namespace %s", ns.Name)
}
for _, cap := range pathPolicy.Capabilities {
if !isPathCapabilityValid(cap) {
return nil, fmt.Errorf(
"Invalid variable capability '%s' in namespace %s", cap, ns.Name)
}
}
pathPolicy.Capabilities = expandVariablesCapabilities(pathPolicy.Capabilities)
}
}
}
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)
}
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)
}
if p.Node != nil && !isPolicyValid(p.Node.Policy) {
return nil, fmt.Errorf("Invalid node policy: %#v", p.Node)
}
if p.Operator != nil && !isPolicyValid(p.Operator.Policy) {
return nil, fmt.Errorf("Invalid operator policy: %#v", p.Operator)
}
if p.Quota != nil && !isPolicyValid(p.Quota.Policy) {
return nil, fmt.Errorf("Invalid quota policy: %#v", p.Quota)
}
if p.Plugin != nil && !p.Plugin.isValid() {
return nil, fmt.Errorf("Invalid plugin policy: %#v", p.Plugin)
}
return p, nil
}
// hclDecode wraps hcl.Decode function but handles any unexpected panics
func hclDecode(p *Policy, rules string) (err error) {
defer func() {
if rerr := recover(); rerr != nil {
err = fmt.Errorf("invalid acl policy: %v", rerr)
}
}()
if err = hcl.Decode(p, rules); err != nil {
return err
}
// Manually parse the policy to fix blocks without labels.
//
// Due to a bug in the way HCL decodes files, a block without a label may
// return an incorrect key value and make it impossible to determine if the
// key was set by the user or incorrectly set by the decoder.
//
// By manually parsing the file we are able to determine if the label is
// missing in the file and set them to an empty string so the policy
// validation can return the appropriate errors.
root, err := hcl.Parse(rules)
if err != nil {
return fmt.Errorf("failed to parse policy: %w", err)
}
list, ok := root.Node.(*ast.ObjectList)
if !ok {
return errors.New("error parsing: root should be an object")
}
nsList := list.Filter("namespace")
for i, nsObj := range nsList.Items {
// Fix missing namespace key.
if len(nsObj.Keys) == 0 {
p.Namespaces[i].Name = ""
}
// Fix missing variable paths.
nsOT, ok := nsObj.Val.(*ast.ObjectType)
if !ok {
continue
}
varsList := nsOT.List.Filter("variables")
if varsList == nil || len(varsList.Items) == 0 {
continue
}
varsObj, ok := varsList.Items[0].Val.(*ast.ObjectType)
if !ok {
continue
}
paths := varsObj.List.Filter("path")
for j, path := range paths.Items {
if len(path.Keys) == 0 {
p.Namespaces[i].Variables.Paths[j].PathSpec = ""
}
}
}
npList := list.Filter("node_pool")
for i, npObj := range npList.Items {
// Fix missing node pool key.
if len(npObj.Keys) == 0 {
p.NodePools[i].Name = ""
}
}
hvList := list.Filter("host_volume")
for i, hvObj := range hvList.Items {
// Fix missing host volume key.
if len(hvObj.Keys) == 0 {
p.HostVolumes[i].Name = ""
}
}
return nil
}