/* * Teleport * Copyright (C) 2023 Gravitational, Inc. * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ package utils import ( "maps" "regexp" "slices" "strings" "github.com/gravitational/trace" lru "github.com/hashicorp/golang-lru/v2" "github.com/gravitational/teleport/api/types" ) // ContainsExpansion returns true if value contains // expansion syntax, e.g. $1 or ${10} func ContainsExpansion(val string) bool { return reExpansion.FindStringIndex(val) != nil } // GlobToRegexp replaces glob-style standalone wildcard values // with real .* regexp-friendly values, does not modify regexp-compatible values, // quotes non-wildcard values func GlobToRegexp(in string) string { return replaceWildcard.ReplaceAllString(regexp.QuoteMeta(in), "(.*)") } // ErrReplaceRegexNotFound is a marker error returned by // [ReplaceRegexp], [RegexpWithConfig], and [ReplaceRegexpWith] to // indicate no matches were found. var ErrReplaceRegexNotFound = &trace.NotFoundError{Message: "no match found"} // ReplaceRegexp replaces value in string, accepts regular expression and simplified // wildcard syntax, it has several important differences with standard lib // regexp replacer: // * Wildcard globs '*' are treated as regular expression .* expression // * Expression is treated as regular expression if it starts with ^ and ends with $ // * Full match is expected, partial replacements ignored // * If there is no match, returns [ErrReplaceRegexNotFound] func ReplaceRegexp(expression string, replaceWith string, input string) (string, error) { expr, err := RegexpWithConfig(expression, RegexpConfig{}) if err != nil { return "", trace.Wrap(err) } return ReplaceRegexpWith(expr, replaceWith, input) } type regexKey struct { expression string ignoreCase bool } // regexpCache interns compiled regular expressions to improve performance. var regexpCache = mustCache[regexKey, *regexp.Regexp](2000) func replaceRegexCached(expression string, config RegexpConfig) (*regexp.Regexp, error) { key := regexKey{expression: expression, ignoreCase: config.IgnoreCase} if expr, ok := regexpCache.Get(key); ok { return expr, nil } if !strings.HasPrefix(expression, "^") || !strings.HasSuffix(expression, "$") { // replace glob-style wildcards with regexp wildcards // for plain strings, and quote all characters that could // be interpreted in regular expression expression = "^" + GlobToRegexp(expression) + "$" } if config.IgnoreCase { expression = "(?i)" + expression } expr, err := regexp.Compile(expression) if err != nil { return nil, trace.BadParameter("%s", err) } regexpCache.Add(key, expr) return expr, nil } // RegexpWithConfig compiles a regular expression given some configuration. // There are several important differences with standard lib (see ReplaceRegexp). func RegexpWithConfig(expression string, config RegexpConfig) (*regexp.Regexp, error) { expr, err := replaceRegexCached(expression, config) return expr, trace.Wrap(err) } // ReplaceRegexpWith replaces string in a given regexp. func ReplaceRegexpWith(expr *regexp.Regexp, replaceWith string, input string) (string, error) { index := expr.FindStringIndex(input) if index == nil { // The returned error is intentionally not wrapped to avoid // capturing stack traces. This method is used by authorization // logic and the additional overhead of strack trace capturing // is a performance bottleneck. return "", ErrReplaceRegexNotFound } return expr.ReplaceAllString(input, replaceWith), nil } // RegexpConfig defines the configuration of the regular expression matcher type RegexpConfig struct { // IgnoreCase specifies whether matching is case-insensitive IgnoreCase bool } // KubeResourceMatchesRegex checks whether the input matches any of the given // expressions. // This function returns as soon as it finds the first match or when MatchString // returns an error. // This function supports regex expressions in the Name and Namespace fields, // but not for the Kind field. // The wildcard (*) expansion is also supported. func KubeResourceMatchesRegexWithVerbsCollector(input types.KubernetesResource, resources []types.KubernetesResource) (bool, []string, error) { verbs := map[string]struct{}{} matchedAny := false for _, resource := range resources { if input.Kind != resource.Kind && resource.Kind != types.Wildcard { continue } if ok, err := MatchString(input.APIGroup, resource.APIGroup); err != nil { return false, nil, trace.Wrap(err) } else if !ok { continue } if ok, err := MatchString(input.Name, resource.Name); err != nil { return false, nil, trace.Wrap(err) } else if !ok { continue } if ok, err := MatchString(input.Namespace, resource.Namespace); err != nil { return false, nil, trace.Wrap(err) } else if !ok { continue } matchedAny = true if len(resource.Verbs) > 0 && resource.Verbs[0] == types.Wildcard { return true, []string{types.Wildcard}, nil } for _, verb := range resource.Verbs { verbs[verb] = struct{}{} } } return matchedAny, slices.Collect(maps.Keys(verbs)), nil } // KubeResourceMatchesRegex checks whether the input matches any of the given // expressions. // This function returns as soon as it finds the first match or when matchString // returns an error. // This function supports regex expressions in the Name and Namespace fields, // but not for the Kind field. // The wildcard (*) expansion is also supported. // input is the resource we are checking for access. // resources is a list of resources that the user has access to - collected from // their roles that match the Kubernetes cluster where the resource is defined. // cond is the deny or allow condition of the role that we are evaluating. func KubeResourceMatchesRegex(input types.KubernetesResource, isClusterWideResource bool, resources []types.KubernetesResource, cond types.RoleConditionType) (bool, error) { if len(input.Verbs) != 1 { return false, trace.BadParameter("only one verb is supported, input: %v", input.Verbs) } verb := input.Verbs[0] // If the user is list/read/watch a namespace, they should be able to see the // namespace they have resources defined for. // This is a special case because we don't want to require the user to have // access to the namespace resource itself. // This is only allowed for the list/read/watch verbs because we don't want // to allow the user to create/update/delete a namespace they don't have // permissions for. targetsReadOnlyNamespace := input.Kind == "namespaces" && slices.Contains([]string{types.KubeVerbGet, types.KubeVerbList, types.KubeVerbWatch}, verb) for _, resource := range resources { // If the resource has a wildcard verb, it matches all verbs. // Otherwise, the resource must have the verb we're looking for otherwise // it doesn't match. // When the resource has a wildcard verb, we only allow one verb in the // resource input. if !isVerbAllowed(resource.Verbs, verb) { continue } switch { case targetsReadOnlyNamespace && cond == types.Allow && resource.Kind != "namespaces" && resource.Namespace != "": // If the user requests a read-only namespace get/list/watch, they should // be able to see the list of namespaces they have resources defined in. // This means that if the user has access to pods in the "foo" namespace, // they should be able to see the "foo" namespace in the list of namespaces // but only if the request is read-only. if ok, err := MatchString(input.Name, resource.Namespace); err != nil || ok { return ok, trace.Wrap(err) } case targetsReadOnlyNamespace && cond == types.Allow && resource.Kind == "namespaces" && resource.Name != "": if ok, err := MatchString(input.Name, resource.Name); err != nil || ok { return ok, trace.Wrap(err) } case input.Kind == "namespaces": if input.Kind != resource.Kind && resource.Kind != types.Wildcard { continue } if ok, err := MatchString(input.APIGroup, resource.APIGroup); err != nil { return false, trace.Wrap(err) } else if !ok { continue } targetNamespace := resource.Namespace if resource.Kind == "namespaces" { targetNamespace = resource.Name } else if resource.Kind == types.Wildcard && (resource.Namespace == "" || resource.Namespace == types.Wildcard) { targetNamespace = resource.Name } if ok, err := MatchString(input.Name, targetNamespace); err != nil || ok { return ok, trace.Wrap(err) } // No match. continue default: if input.Kind != resource.Kind && resource.Kind != types.Wildcard { continue } if ok, err := MatchString(input.APIGroup, resource.APIGroup); err != nil { return false, trace.Wrap(err) } else if !ok { continue } if ok, err := MatchString(input.Name, resource.Name); err != nil { return false, trace.Wrap(err) } else if !ok { continue } if input.Namespace == "" && resource.Namespace != "" && resource.Namespace != types.Wildcard { continue } // At this point everything else matched. If we match the namespace as well, we have a match. if ok, err := MatchString(input.Namespace, resource.Namespace); err != nil || ok { return ok, trace.Wrap(err) } } } return false, nil } // KubeResourceCouldMatchRules assess whether the user is permitted to perform its request // based on the defined kubernetes_resource rules. The aim is to catch cases when the user // has no access and present then a more user-friendly error message instead of returning // an empty list. // This function is not responsible for enforcing access rules. func KubeResourceCouldMatchRules(input types.KubernetesResource, isClusterWideResource bool, resources []types.KubernetesResource, cond types.RoleConditionType) (bool, error) { if len(input.Verbs) != 1 { return false, trace.BadParameter("only one verb is supported, input: %v", input.Verbs) } if input.Name != "" { return false, trace.BadParameter("name is not supported for KubeResourceCouldMatchRules") } verb := input.Verbs[0] isDeny := cond == types.Deny // If the user is allowed to list/read/watch a resource, they should be able to see the // namespace in which the resource is. // This is a special case because we don't want to require the user to have // access to the namespace resource itself. // This is only allowed for the list/read/watch verbs because we don't want // to allow the user to create/update/delete a namespace they don't have // permissions for. targetsReadOnlyNamespace := input.Kind == "namespaces" && slices.Contains([]string{types.KubeVerbGet, types.KubeVerbList, types.KubeVerbWatch}, verb) for _, resource := range resources { // If the resource has a wildcard verb, it matches all verbs. // Otherwise, the resource must have the verb we're looking for otherwise // it doesn't match. // When the resource has a wildcard verb, we only allow one verb in the // resource input. if !isVerbAllowed(resource.Verbs, verb) { continue } switch { case targetsReadOnlyNamespace && isDeny: // For read-only namespace request, match the deny only if there is an explicit deny, // i.e., if we have a wildcard deny, we should still be able to get namespaces. // If the group doesn't match and is not wildcard, skip. if resource.Kind != "namespaces" { continue // The only possible way to match in deny is to have an explicit 'namespaces' rule. } if ok, err := MatchString(input.Name, resource.Name); err != nil || ok { return ok, trace.Wrap(err) } continue case targetsReadOnlyNamespace && !isDeny && resource.Kind != "namespaces" && resource.Namespace != "": // If the user requests a read-only namespace get/list/watch, they should // be able to see the list of namespaces they have resources defined in. // This means that if the user has access to pods in the "foo" namespace, // they should be able to see the "foo" namespace in the list of namespaces // but only if the request is read-only. return true, nil default: // If the kind doesn't match and is not wildcard, skip. if input.Kind != resource.Kind && resource.Kind != types.Wildcard { continue } // If the group doesn't match and is not wildcard, skip. if ok, err := MatchString(input.APIGroup, resource.APIGroup); err != nil { return false, trace.Wrap(err) } else if !ok { continue } // if the resource is cluster-wide, the command is deny and it's a wildcard resource // match all resources. if isClusterWideResource && isDeny && resource.Name == types.Wildcard { return true, nil } else if isClusterWideResource { return !isDeny, nil } // If we are listing a namespaced resource, we can't match against a cluster-wide entry. if isDeny && resource.Namespace == "" { return false, nil } // at this point, the resource is namespaced and if the namespace is empty, // the user is requesting resources in all namespaces. // Since he has some rule defined, we should return. isAllowOrFullDeny := !isDeny || isDeny && resource.Name == types.Wildcard && resource.Namespace == types.Wildcard if input.Namespace == "" && isAllowOrFullDeny { return isAllowOrFullDeny, nil } if ok, err := MatchString(input.Namespace, resource.Namespace); err != nil { return false, trace.Wrap(err) } else if !ok { continue } if !isDeny || isDeny && resource.Name == types.Wildcard { return !isDeny || isDeny && resource.Name == types.Wildcard, nil } } } return false, nil } // isVerbAllowed returns true if the verb is allowed in the resource. // If the resource has a wildcard verb, it matches all verbs, otherwise // the resource must have the verb we're looking for. func isVerbAllowed(allowedVerbs []string, verb string) bool { return len(allowedVerbs) != 0 && (allowedVerbs[0] == types.Wildcard || slices.Contains(allowedVerbs, verb)) } // SliceMatchesRegex checks if input matches any of the expressions. The // match is always evaluated as a regex either an exact match or regexp. func SliceMatchesRegex(input string, expressions []string) (bool, error) { for _, expression := range expressions { result, err := MatchString(input, expression) if err != nil || result { return result, trace.Wrap(err) } } return false, nil } // RegexMatchesAny returns true if [expression] matches any element of // [inputs]. [expression] support globbing ("env-*") or normal regexp syntax if // surrounded with ^$ ("^env-.*$"). func RegexMatchesAny(inputs []string, expression string) (bool, error) { expr, err := compileRegexCached(expression) if err != nil { return false, trace.Wrap(err) } for _, input := range inputs { // Since the expression is always surrounded by ^ and $ this is an exact // match for either a plain string (for example ^hello$) or for a regexp // (for example ^hel*o$). if expr.MatchString(input) { return true, nil } } return false, nil } // mustCache initializes a new [lru.Cache] with the provided size. // A panic will be triggered if the creation of the cache fails. func mustCache[K comparable, V any](size int) *lru.Cache[K, V] { cache, err := lru.New[K, V](size) if err != nil { panic(err) } return cache } // MatchString will match an input against the given expression. The expression is cached for later use. func MatchString(input, expression string) (bool, error) { expr, err := compileRegexCached(expression) if err != nil { return false, trace.BadParameter("%s", err) } // Since the expression is always surrounded by ^ and $ this is an exact // match for either a plain string (for example ^hello$) or for a regexp // (for example ^hel*o$). return expr.MatchString(input), nil } // CompileExpression compiles the given regex expression with Teleport's custom globbing // and quoting logic. func CompileExpression(expression string) (*regexp.Regexp, error) { if !strings.HasPrefix(expression, "^") || !strings.HasSuffix(expression, "$") { // replace glob-style wildcards with regexp wildcards // for plain strings, and quote all characters that could // be interpreted in regular expression expression = "^" + GlobToRegexp(expression) + "$" } expr, err := regexp.Compile(expression) if err != nil { return nil, trace.BadParameter("%s", err) } return expr, nil } func compileRegexCached(expression string) (*regexp.Regexp, error) { key := regexKey{expression: expression} if expr, ok := regexpCache.Get(key); ok { return expr, nil } expr, err := CompileExpression(expression) if err != nil { return nil, trace.Wrap(err) } regexpCache.Add(key, expr) return expr, nil } var ( replaceWildcard = regexp.MustCompile(`(\\\*)`) reExpansion = regexp.MustCompile(`\$[^\$]+`) )