Files
teleport/lib/utils/replace.go
T
Jake Alti 808188b10a kube: precompiled per-request fast matcher for RBAC filtering (#64588)
Compile allowed/denied name and namespace patterns once per request
into a fast matcher instead of calling matchKubernetesResource per item.

The per-item cost is not regex compilation (already cached) but cache
lookups, iteration over all rules, per-field matching, and branching
logic. For 5k pods x 3 rules x 3 fields = ~45k cache lookups per
request.

The fast matcher resolves kind, verb, and apiGroup at compile time
(constant per request) and only checks name and namespace per item.
Falls back to the existing defaultMatcher for namespace-kind requests
which have special cross-field matching logic.
2026-03-31 14:50:43 +00:00

464 lines
17 KiB
Go

/*
* 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 <http://www.gnu.org/licenses/>.
*/
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
}
expression = expressionToRegexp(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.
// It short-circuits on a wildcard in position 0, otherwise checks whether the verb appears in the list.
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)
}
if slices.ContainsFunc(inputs, expr.MatchString) {
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
}
// IsRegexp returns true if the expression is a raw regex pattern (starts with ^ and ends with $).
func IsRegexp(expression string) bool {
return strings.HasPrefix(expression, "^") && strings.HasSuffix(expression, "$")
}
// expressionToRegexp converts a Teleport expression to a regexp string.
func expressionToRegexp(expression string) string {
if IsRegexp(expression) {
return expression
}
// replace glob-style wildcards with regexp wildcards
// for plain strings, and quote all characters that could
// be interpreted in regular expression
return "^" + GlobToRegexp(expression) + "$"
}
// CompileExpression compiles the given regex expression with Teleport's custom globbing
// and quoting logic.
func CompileExpression(expression string) (*regexp.Regexp, error) {
expression = expressionToRegexp(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(`\$[^\$]+`)
)