// 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 }