diff --git a/lib/auth/auth_with_roles.go b/lib/auth/auth_with_roles.go index ddb35da8b4ca4..66760e1f4a7b9 100644 --- a/lib/auth/auth_with_roles.go +++ b/lib/auth/auth_with_roles.go @@ -1472,31 +1472,18 @@ func (a *ServerWithRoles) checkUnifiedAccess(resource types.ResourceWithLabels, // ListUnifiedResources returns a paginated list of unified resources filtered by user access. func (a *ServerWithRoles) ListUnifiedResources(ctx context.Context, req *proto.ListUnifiedResourcesRequest) (*proto.ListUnifiedResourcesResponse, error) { - // Fetch full list of resources in the backend. - var ( - unifiedResources types.ResourcesWithLabels - nextKey string - ) - filter := services.MatchResourceFilter{ - Labels: req.Labels, - SearchKeywords: req.SearchKeywords, - PredicateExpression: req.PredicateExpression, - Kinds: req.Kinds, + Labels: req.Labels, + SearchKeywords: req.SearchKeywords, + Kinds: req.Kinds, } - // If a predicate expression was provided, evaluate it with an empty - // server to determine if the expression is valid before attempting - // to do any listing. - if filter.PredicateExpression != "" { - parser, err := services.NewResourceParser(&types.ServerV2{}) + if req.PredicateExpression != "" { + expression, err := services.NewResourceExpression(req.PredicateExpression) if err != nil { return nil, trace.Wrap(err) } - - if _, err := parser.EvalBoolPredicate(filter.PredicateExpression); err != nil { - return nil, trace.BadParameter("failed to parse predicate expression: %s", err.Error()) - } + filter.PredicateExpression = expression } // Populate resourceAccessMap with any access errors the user has for each possible @@ -1562,6 +1549,11 @@ func (a *ServerWithRoles) ListUnifiedResources(ctx context.Context, req *proto.L return nil, trace.Wrap(err) } + // Fetch full list of resources in the backend. + var ( + unifiedResources types.ResourcesWithLabels + nextKey string + ) if req.PinnedOnly { prefs, err := a.authServer.GetUserPreferences(ctx, a.context.User.GetName()) if err != nil { @@ -1823,11 +1815,19 @@ func (a *ServerWithRoles) ListResources(ctx context.Context, req proto.ListResou // `ListResources`) to ensure that it will be applied only to resources // the user has access to. filter := services.MatchResourceFilter{ - ResourceKind: req.ResourceType, - Labels: req.Labels, - SearchKeywords: req.SearchKeywords, - PredicateExpression: req.PredicateExpression, + ResourceKind: req.ResourceType, + Labels: req.Labels, + SearchKeywords: req.SearchKeywords, } + + if req.PredicateExpression != "" { + expression, err := services.NewResourceExpression(req.PredicateExpression) + if err != nil { + return nil, trace.Wrap(err) + } + filter.PredicateExpression = expression + } + req.Labels = nil req.SearchKeywords = nil req.PredicateExpression = "" @@ -2056,15 +2056,24 @@ func (a *ServerWithRoles) listResourcesWithSort(ctx context.Context, req proto.L return nil, trace.NotImplemented("resource type %q is not supported for listResourcesWithSort", req.ResourceType) } + params := local.FakePaginateParams{ + ResourceType: req.ResourceType, + Limit: req.Limit, + Labels: req.Labels, + SearchKeywords: req.SearchKeywords, + StartKey: req.StartKey, + } + + if req.PredicateExpression != "" { + expression, err := services.NewResourceExpression(req.PredicateExpression) + if err != nil { + return nil, trace.Wrap(err) + } + params.PredicateExpression = expression + } + // Apply request filters and get pagination info. - resp, err := local.FakePaginate(resources, local.FakePaginateParams{ - ResourceType: req.ResourceType, - Limit: req.Limit, - Labels: req.Labels, - SearchKeywords: req.SearchKeywords, - PredicateExpression: req.PredicateExpression, - StartKey: req.StartKey, - }) + resp, err := local.FakePaginate(resources, params) if err != nil { return nil, trace.Wrap(err) } diff --git a/lib/cache/cache.go b/lib/cache/cache.go index 7172e8afed3c8..84f0eccbc5803 100644 --- a/lib/cache/cache.go +++ b/lib/cache/cache.go @@ -3048,14 +3048,23 @@ func (c *Cache) ListResources(ctx context.Context, req proto.ListResourcesReques return nil, trace.Wrap(err) } - return local.FakePaginate(servers.AsResources(), local.FakePaginateParams{ - ResourceType: req.ResourceType, - Limit: req.Limit, - Labels: req.Labels, - SearchKeywords: req.SearchKeywords, - PredicateExpression: req.PredicateExpression, - StartKey: req.StartKey, - }) + params := local.FakePaginateParams{ + ResourceType: req.ResourceType, + Limit: req.Limit, + Labels: req.Labels, + SearchKeywords: req.SearchKeywords, + StartKey: req.StartKey, + } + + if req.PredicateExpression != "" { + expression, err := services.NewResourceExpression(req.PredicateExpression) + if err != nil { + return nil, trace.Wrap(err) + } + params.PredicateExpression = expression + } + + return local.FakePaginate(servers.AsResources(), params) } } diff --git a/lib/kube/grpc/grpc.go b/lib/kube/grpc/grpc.go index d95c329cc0bc9..11710837140db 100644 --- a/lib/kube/grpc/grpc.go +++ b/lib/kube/grpc/grpc.go @@ -216,10 +216,17 @@ func (s *Server) listKubernetesResources( limit := int(req.Limit) filter := services.MatchResourceFilter{ - ResourceKind: req.ResourceType, - Labels: req.Labels, - SearchKeywords: req.SearchKeywords, - PredicateExpression: req.PredicateExpression, + ResourceKind: req.ResourceType, + Labels: req.Labels, + SearchKeywords: req.SearchKeywords, + } + + if req.PredicateExpression != "" { + expression, err := services.NewResourceExpression(req.PredicateExpression) + if err != nil { + return nil, trace.Wrap(err) + } + filter.PredicateExpression = expression } rsp := &proto.ListKubernetesResourcesResponse{} @@ -525,19 +532,26 @@ func (s *Server) listResourcesUsingFakePagination( return nil, trace.Wrap(err) } } + + // map the request to the fake pagination request. + params := local.FakePaginateParams{ + StartKey: req.StartKey, + Limit: req.Limit, + ResourceType: req.ResourceType, + Labels: req.Labels, + SearchKeywords: req.SearchKeywords, + } + + if req.PredicateExpression != "" { + expression, err := services.NewResourceExpression(req.PredicateExpression) + if err != nil { + return nil, trace.Wrap(err) + } + params.PredicateExpression = expression + } + // Apply request filters and get pagination info. - fakeRsp, err := local.FakePaginate( - sortedClusters.AsResources(), - // map the request to the fake pagination request. - local.FakePaginateParams{ - StartKey: req.StartKey, - Limit: req.Limit, - ResourceType: req.ResourceType, - Labels: req.Labels, - PredicateExpression: req.PredicateExpression, - SearchKeywords: req.SearchKeywords, - }, - ) + fakeRsp, err := local.FakePaginate(sortedClusters.AsResources(), params) if err != nil { return nil, trace.Wrap(err) } diff --git a/lib/services/fuzz_test.go b/lib/services/fuzz_test.go index fe020c3268e84..dcf7e9d70650f 100644 --- a/lib/services/fuzz_test.go +++ b/lib/services/fuzz_test.go @@ -111,11 +111,13 @@ func FuzzParserEvalBoolPredicate(f *testing.F) { }) require.NoError(t, err) - parser, err := NewResourceParser(resource) - require.NoError(t, err) - require.NotPanics(t, func() { - parser.EvalBoolPredicate(expr) + parser, err := NewResourceExpression(expr) + if err != nil { + return + } + + parser.Evaluate(resource) }) }) } diff --git a/lib/services/local/desktops.go b/lib/services/local/desktops.go index 2ec0c90c67176..e4363ee912018 100644 --- a/lib/services/local/desktops.go +++ b/lib/services/local/desktops.go @@ -180,10 +180,17 @@ func (s *WindowsDesktopService) ListWindowsDesktops(ctx context.Context, req typ rangeStart := backend.Key(windowsDesktopsPrefix, req.StartKey) rangeEnd := backend.RangeEnd(backend.ExactKey(windowsDesktopsPrefix)) filter := services.MatchResourceFilter{ - ResourceKind: types.KindWindowsDesktop, - Labels: req.Labels, - SearchKeywords: req.SearchKeywords, - PredicateExpression: req.PredicateExpression, + ResourceKind: types.KindWindowsDesktop, + Labels: req.Labels, + SearchKeywords: req.SearchKeywords, + } + + if req.PredicateExpression != "" { + expression, err := services.NewResourceExpression(req.PredicateExpression) + if err != nil { + return nil, trace.Wrap(err) + } + filter.PredicateExpression = expression } // Get most limit+1 results to determine if there will be a next key. @@ -245,10 +252,17 @@ func (s *WindowsDesktopService) ListWindowsDesktopServices(ctx context.Context, rangeStart := backend.Key(windowsDesktopServicesPrefix, req.StartKey) rangeEnd := backend.RangeEnd(backend.ExactKey(windowsDesktopServicesPrefix)) filter := services.MatchResourceFilter{ - ResourceKind: types.KindWindowsDesktopService, - Labels: req.Labels, - SearchKeywords: req.SearchKeywords, - PredicateExpression: req.PredicateExpression, + ResourceKind: types.KindWindowsDesktopService, + Labels: req.Labels, + SearchKeywords: req.SearchKeywords, + } + + if req.PredicateExpression != "" { + expression, err := services.NewResourceExpression(req.PredicateExpression) + if err != nil { + return nil, trace.Wrap(err) + } + filter.PredicateExpression = expression } // Get most limit+1 results to determine if there will be a next key. diff --git a/lib/services/local/presence.go b/lib/services/local/presence.go index c4ff5dda57f57..d3b6203e404e7 100644 --- a/lib/services/local/presence.go +++ b/lib/services/local/presence.go @@ -36,6 +36,7 @@ import ( "github.com/gravitational/teleport/lib/backend" "github.com/gravitational/teleport/lib/services" "github.com/gravitational/teleport/lib/utils" + "github.com/gravitational/teleport/lib/utils/typical" ) // PresenceService records and reports the presence of all components @@ -1587,10 +1588,17 @@ func (s *PresenceService) listResources(ctx context.Context, req proto.ListResou rangeStart := backend.Key(append(keyPrefix, req.StartKey)...) rangeEnd := backend.RangeEnd(backend.ExactKey(keyPrefix...)) filter := services.MatchResourceFilter{ - ResourceKind: req.ResourceType, - Labels: req.Labels, - SearchKeywords: req.SearchKeywords, - PredicateExpression: req.PredicateExpression, + ResourceKind: req.ResourceType, + Labels: req.Labels, + SearchKeywords: req.SearchKeywords, + } + + if req.PredicateExpression != "" { + expression, err := services.NewResourceExpression(req.PredicateExpression) + if err != nil { + return nil, trace.Wrap(err) + } + filter.PredicateExpression = expression } // Get most limit+1 results to determine if there will be a next key. @@ -1724,14 +1732,23 @@ func (s *PresenceService) listResourcesWithSort(ctx context.Context, req proto.L return nil, trace.NotImplemented("resource type %q is not supported for ListResourcesWithSort", req.ResourceType) } - return FakePaginate(resources, FakePaginateParams{ - ResourceType: req.ResourceType, - Limit: req.Limit, - Labels: req.Labels, - SearchKeywords: req.SearchKeywords, - PredicateExpression: req.PredicateExpression, - StartKey: req.StartKey, - }) + params := FakePaginateParams{ + ResourceType: req.ResourceType, + Limit: req.Limit, + Labels: req.Labels, + SearchKeywords: req.SearchKeywords, + StartKey: req.StartKey, + } + + if req.PredicateExpression != "" { + expression, err := services.NewResourceExpression(req.PredicateExpression) + if err != nil { + return nil, trace.Wrap(err) + } + params.PredicateExpression = expression + } + + return FakePaginate(resources, params) } // FakePaginateParams is used in FakePaginate to help filter down listing of resources into pages @@ -1751,7 +1768,7 @@ type FakePaginateParams struct { // Labels is a label-based matcher if non-empty. Labels map[string]string // PredicateExpression defines boolean conditions that will be matched against the resource. - PredicateExpression string + PredicateExpression typical.Expression[types.ResourceWithLabels, bool] // SearchKeywords is a list of search keywords to match against resource field values. SearchKeywords []string // SortBy describes which resource field and which direction to sort by. diff --git a/lib/services/local/presence_test.go b/lib/services/local/presence_test.go index 61c0796952fc8..dbe5ed1e084d4 100644 --- a/lib/services/local/presence_test.go +++ b/lib/services/local/presence_test.go @@ -862,12 +862,11 @@ func TestListResources_Helpers(t *testing.T) { require.NoError(t, err) return FakePaginate(types.Servers(nodes).AsResources(), FakePaginateParams{ - ResourceType: req.ResourceType, - Limit: req.Limit, - Labels: req.Labels, - SearchKeywords: req.SearchKeywords, - PredicateExpression: req.PredicateExpression, - StartKey: req.StartKey, + ResourceType: req.ResourceType, + Limit: req.Limit, + Labels: req.Labels, + SearchKeywords: req.SearchKeywords, + StartKey: req.StartKey, }) }, }, diff --git a/lib/services/matchers.go b/lib/services/matchers.go index 9b1dea41d4f6a..9922a36fb32c8 100644 --- a/lib/services/matchers.go +++ b/lib/services/matchers.go @@ -25,6 +25,7 @@ import ( "github.com/gravitational/teleport/api/types" apiutils "github.com/gravitational/teleport/api/utils" azureutils "github.com/gravitational/teleport/api/utils/azure" + "github.com/gravitational/teleport/lib/utils/typical" ) // ResourceMatcher matches cluster resources. @@ -218,20 +219,6 @@ func MatchResourceByFilters(resource types.ResourceWithLabels, filter MatchResou } func matchResourceByFilters(resource types.ResourceWithLabels, filter MatchResourceFilter) (bool, error) { - if filter.PredicateExpression != "" { - parser, err := NewResourceParser(resource) - if err != nil { - return false, trace.Wrap(err) - } - - switch match, err := parser.EvalBoolPredicate(filter.PredicateExpression); { - case err != nil: - return false, trace.BadParameter("failed to parse predicate expression: %s", err.Error()) - case !match: - return false, nil - } - } - if !types.MatchKinds(resource, filter.Kinds) { return false, nil } @@ -240,10 +227,21 @@ func matchResourceByFilters(resource types.ResourceWithLabels, filter MatchResou return false, nil } - if !resource.MatchSearch(filter.SearchKeywords) { + if len(filter.SearchKeywords) > 0 && !resource.MatchSearch(filter.SearchKeywords) { return false, nil } + if filter.PredicateExpression != nil { + match, err := filter.PredicateExpression.Evaluate(resource) + if err != nil { + return false, trace.Wrap(err) + } + + if !match { + return false, nil + } + } + return true, nil } @@ -280,7 +278,7 @@ type MatchResourceFilter struct { // SearchKeywords is a list of search keywords to match. SearchKeywords []string // PredicateExpression holds boolean conditions that must be matched. - PredicateExpression string + PredicateExpression typical.Expression[types.ResourceWithLabels, bool] // Kinds is a list of resourceKinds to be used when doing a unified resource query. // It will filter out any kind not present in the list. If the list is not present or empty // then all kinds are valid and will be returned (still subject to other included filters) @@ -292,6 +290,6 @@ type MatchResourceFilter struct { func (m *MatchResourceFilter) IsSimple() bool { return len(m.Labels) == 0 && len(m.SearchKeywords) == 0 && - m.PredicateExpression == "" && + m.PredicateExpression == nil && len(m.Kinds) == 0 } diff --git a/lib/services/matchers_test.go b/lib/services/matchers_test.go index dcc8f72a94a39..6e8ce171b4065 100644 --- a/lib/services/matchers_test.go +++ b/lib/services/matchers_test.go @@ -148,10 +148,11 @@ func TestMatchResourceByFilters_Helper(t *testing.T) { resource := types.ResourceWithLabels(server) testcases := []struct { - name string - filters MatchResourceFilter - assertErr require.ErrorAssertionFunc - assertMatch require.BoolAssertionFunc + name string + predicateExpression string + filters MatchResourceFilter + assertErr require.ErrorAssertionFunc + assertMatch require.BoolAssertionFunc }{ { name: "empty filters", @@ -159,21 +160,21 @@ func TestMatchResourceByFilters_Helper(t *testing.T) { assertMatch: require.True, }, { - name: "all match", + name: "all match", + predicateExpression: `resource.spec.hostname == "foo"`, filters: MatchResourceFilter{ - PredicateExpression: `resource.spec.hostname == "foo"`, - SearchKeywords: []string{"banana"}, - Labels: map[string]string{"os": "mac"}, + SearchKeywords: []string{"banana"}, + Labels: map[string]string{"os": "mac"}, }, assertErr: require.NoError, assertMatch: require.True, }, { - name: "no match", + name: "no match", + predicateExpression: `labels.env == "no-match"`, filters: MatchResourceFilter{ - PredicateExpression: `labels.env == "no-match"`, - SearchKeywords: []string{"no", "match"}, - Labels: map[string]string{"no": "match"}, + SearchKeywords: []string{"no", "match"}, + Labels: map[string]string{"no": "match"}, }, assertErr: require.NoError, assertMatch: require.False, @@ -203,28 +204,22 @@ func TestMatchResourceByFilters_Helper(t *testing.T) { assertMatch: require.True, }, { - name: "expression match", - filters: MatchResourceFilter{ - PredicateExpression: `labels.env == "prod" && exists(labels.os)`, - }, - assertErr: require.NoError, - assertMatch: require.True, + name: "expression match", + predicateExpression: `labels.env == "prod" && exists(labels.os)`, + assertErr: require.NoError, + assertMatch: require.True, }, { - name: "no expression match", - filters: MatchResourceFilter{ - PredicateExpression: `labels.env == "no-match"`, - }, - assertErr: require.NoError, - assertMatch: require.False, + name: "no expression match", + predicateExpression: `labels.env == "no-match"`, + assertErr: require.NoError, + assertMatch: require.False, }, { - name: "error in expr", - filters: MatchResourceFilter{ - PredicateExpression: `labels.env == prod`, - }, - assertErr: require.Error, - assertMatch: require.False, + name: "error in expr", + predicateExpression: `labels.env == prod`, + assertErr: require.Error, + assertMatch: require.False, }, { name: "label match", @@ -259,31 +254,31 @@ func TestMatchResourceByFilters_Helper(t *testing.T) { assertMatch: require.False, }, { - name: "partial match is no match: search", + name: "partial match is no match: search", + predicateExpression: `resource.spec.hostname == "foo"`, filters: MatchResourceFilter{ - PredicateExpression: `resource.spec.hostname == "foo"`, - Labels: map[string]string{"os": "mac"}, - SearchKeywords: []string{"no", "match"}, + Labels: map[string]string{"os": "mac"}, + SearchKeywords: []string{"no", "match"}, }, assertErr: require.NoError, assertMatch: require.False, }, { - name: "partial match is no match: labels", + name: "partial match is no match: labels", + predicateExpression: `resource.spec.hostname == "foo"`, filters: MatchResourceFilter{ - PredicateExpression: `resource.spec.hostname == "foo"`, - Labels: map[string]string{"no": "match"}, - SearchKeywords: []string{"mac", "env"}, + Labels: map[string]string{"no": "match"}, + SearchKeywords: []string{"mac", "env"}, }, assertErr: require.NoError, assertMatch: require.False, }, { - name: "partial match is no match: expression", + name: "partial match is no match: expression", + predicateExpression: `labels.env == "no-match"`, filters: MatchResourceFilter{ - PredicateExpression: `labels.env == "no-match"`, - Labels: map[string]string{"os": "mac"}, - SearchKeywords: []string{"mac", "env"}, + Labels: map[string]string{"os": "mac"}, + SearchKeywords: []string{"mac", "env"}, }, assertErr: require.NoError, assertMatch: require.False, @@ -295,6 +290,12 @@ func TestMatchResourceByFilters_Helper(t *testing.T) { t.Run(tc.name, func(t *testing.T) { t.Parallel() + if tc.predicateExpression != "" { + parser, err := NewResourceExpression(tc.predicateExpression) + require.NoError(t, err) + tc.filters.PredicateExpression = parser + } + match, err := matchResourceByFilters(resource, tc.filters) tc.assertErr(t, err) tc.assertMatch(t, match) @@ -342,10 +343,10 @@ func TestMatchAndFilterKubeClusters(t *testing.T) { } testcases := []struct { - name string - filters MatchResourceFilter - expectedLen int - assertMatch require.BoolAssertionFunc + name string + predicateExpression string + expectedLen int + assertMatch require.BoolAssertionFunc }{ { name: "empty values", @@ -353,35 +354,27 @@ func TestMatchAndFilterKubeClusters(t *testing.T) { assertMatch: require.True, }, { - name: "all match", - expectedLen: 3, - filters: MatchResourceFilter{ - PredicateExpression: `labels.os == "mac"`, - }, - assertMatch: require.True, + name: "all match", + expectedLen: 3, + predicateExpression: `labels.os == "mac"`, + assertMatch: require.True, }, { - name: "some match", - expectedLen: 2, - filters: MatchResourceFilter{ - PredicateExpression: `labels.env == "prod"`, - }, - assertMatch: require.True, + name: "some match", + expectedLen: 2, + predicateExpression: `labels.env == "prod"`, + assertMatch: require.True, }, { - name: "single match", - expectedLen: 1, - filters: MatchResourceFilter{ - PredicateExpression: `labels.env == "staging"`, - }, - assertMatch: require.True, + name: "single match", + expectedLen: 1, + predicateExpression: `labels.env == "staging"`, + assertMatch: require.True, }, { - name: "no match", - filters: MatchResourceFilter{ - PredicateExpression: `labels.env == "no-match"`, - }, - assertMatch: require.False, + name: "no match", + predicateExpression: `labels.env == "no-match"`, + assertMatch: require.False, }, } @@ -390,11 +383,19 @@ func TestMatchAndFilterKubeClusters(t *testing.T) { t.Run(tc.name, func(t *testing.T) { t.Parallel() + var filters MatchResourceFilter + if tc.predicateExpression != "" { + expression, err := NewResourceExpression(tc.predicateExpression) + require.NoError(t, err) + + filters.PredicateExpression = expression + } + kubeServers := getKubeServers() atLeastOneMatch := false matchedServers := make([]types.KubeServer, 0, len(kubeServers)) for _, kubeServer := range kubeServers { - match, err := matchAndFilterKubeClusters(types.ResourceWithLabels(kubeServer), tc.filters) + match, err := matchAndFilterKubeClusters(types.ResourceWithLabels(kubeServer), filters) require.NoError(t, err) if match { atLeastOneMatch = true @@ -414,7 +415,8 @@ func TestMatchAndFilterKubeClusters(t *testing.T) { func TestMatchResourceByFilters(t *testing.T) { t.Parallel() - filterExpression := `resource.metadata.name == "foo"` + filterExpression, err := NewResourceExpression(`resource.metadata.name == "foo"`) + require.NoError(t, err) testcases := []struct { name string diff --git a/lib/services/parser.go b/lib/services/parser.go index f227274a46420..acac6d8378d43 100644 --- a/lib/services/parser.go +++ b/lib/services/parser.go @@ -32,6 +32,7 @@ import ( "github.com/gravitational/teleport/api/types/events" "github.com/gravitational/teleport/api/types/wrappers" "github.com/gravitational/teleport/lib/session" + "github.com/gravitational/teleport/lib/utils/typical" ) // RuleContext specifies context passed to the @@ -729,136 +730,69 @@ func newParserForIdentifierSubcondition(ctx RuleContext, identifier string) (pre }) } -// NewResourceParser returns a parser made for boolean expressions based on a -// json-serialiable resource. Customized to allow short identifiers common in all +// NewResourceExpression returns a [typical.Expression] that is to be evaluated against a +// [types.ResourceWithLabels]. It is customized to allow short identifiers common in all // resources: -// - shorthand `name` refers to `resource.spec.hostname` for node resources or it refers +// - shorthand `name` refers to `resource.spec.hostname` for node resources, or it refers // to `resource.metadata.name` for all other resources eg: `name == "app-name-jenkins"` // - shorthand `labels` refers to resource `resource.metadata.labels + resource.spec.dynamic_labels` // eg: `labels.env == "prod"` // // All other fields can be referenced by starting expression with identifier `resource` // followed by the names of the json fields ie: `resource.spec.public_addr`. -func NewResourceParser(resource types.ResourceWithLabels) (BoolPredicateParser, error) { - predEquals := func(a interface{}, b interface{}) predicate.BoolPredicate { - switch aval := a.(type) { - case label: - bval, ok := b.(string) - return func() bool { - return ok && aval.value == bval - } - default: - return predicate.Equals(a, b) - } - } - predPrefix := func(a interface{}, prefix string) predicate.BoolPredicate { - switch aval := a.(type) { - case label: - return func() bool { - return strings.HasPrefix(aval.value, prefix) - } - case string: - return func() bool { - return strings.HasPrefix(aval, prefix) - } - default: - return func() bool { - return false - } - } - } - - p, err := predicate.NewParser(predicate.Def{ - Operators: predicate.Operators{ - AND: predicate.And, - OR: predicate.Or, - NOT: predicate.Not, - EQ: predEquals, - NEQ: func(a interface{}, b interface{}) predicate.BoolPredicate { - return predicate.Not(predEquals(a, b)) - }, - }, - Functions: map[string]interface{}{ - "hasPrefix": predPrefix, - "equals": predEquals, - // search allows fuzzy matching against select field values. - "search": func(searchVals ...string) predicate.BoolPredicate { - return func() bool { - return resource.MatchSearch(searchVals) - } - }, - // exists checks for an existence of a label by checking - // if a key exists. Label value are unchecked. - "exists": func(l label) predicate.BoolPredicate { - return func() bool { - return len(l.key) > 0 - } - }, - }, - GetIdentifier: func(fields []string) (interface{}, error) { - switch fields[0] { - case ResourceLabelsIdentifier: - switch { - // Field length of 1 means the user is using - // an index expression ie: labels["env"], which the - // parser will expect a map for lookup in `GetProperty`. - case len(fields) == 1: - return resource, nil - case len(fields) > 2: - return nil, trace.BadParameter("only two fields are supported with identifier %q, got %d: %v", ResourceLabelsIdentifier, len(fields), fields) - default: - key := fields[1] - val, ok := resource.GetLabel(key) - if ok { - return label{key: key, value: val}, nil - } - return label{}, nil - } - - case ResourceNameIdentifier: - if len(fields) > 1 { - return nil, trace.BadParameter("only one field are supported with identifier %q, got %d: %v", ResourceNameIdentifier, len(fields), fields) - } - +func NewResourceExpression(expression string) (typical.Expression[types.ResourceWithLabels, bool], error) { + parser, err := typical.NewParser[types.ResourceWithLabels, bool](typical.ParserSpec[types.ResourceWithLabels]{ + Variables: map[string]typical.Variable{ + "resource.metadata.labels": typical.DynamicVariable(func(r types.ResourceWithLabels) (map[string]string, error) { + return r.GetStaticLabels(), nil + }), + "resource.metadata.name": typical.DynamicVariable(func(r types.ResourceWithLabels) (string, error) { + return r.GetName(), nil + }), + "labels": typical.DynamicMapFunction(func(r types.ResourceWithLabels, key string) (string, error) { + val, _ := r.GetLabel(key) + return val, nil + }), + "name": typical.DynamicVariable(func(r types.ResourceWithLabels) (string, error) { // For nodes, the resource "name" that user expects is the - // nodes hostname, not its UUID. Currently for other resources, + // nodes hostname, not its UUID. Currently, for other resources, // the metadata.name returns the name as expected. - if server, ok := resource.(types.Server); ok { + if server, ok := r.(types.Server); ok { return server.GetHostname(), nil } - return resource.GetName(), nil - case ResourceIdentifier: - return predicate.GetFieldByTag(resource, teleport.JSON, fields[1:]) - default: - return nil, trace.NotFound("identifier %q is not defined", strings.Join(fields, ".")) - } + return r.GetName(), nil + }), }, - GetProperty: func(mapVal, keyVal interface{}) (interface{}, error) { - r, ok := mapVal.(types.ResourceWithLabels) - if !ok { - return GetStringMapValue(mapVal, keyVal) - } - - key, ok := keyVal.(string) - if !ok { - return nil, trace.BadParameter("only string keys are supported") + Functions: map[string]typical.Function{ + "hasPrefix": typical.BinaryFunction[types.ResourceWithLabels](func(s, suffix string) (bool, error) { + return strings.HasPrefix(s, suffix), nil + }), + "equals": typical.BinaryFunction[types.ResourceWithLabels](func(a, b string) (bool, error) { + return strings.Compare(a, b) == 0, nil + }), + "search": typical.UnaryVariadicFunctionWithEnv(func(r types.ResourceWithLabels, v ...string) (bool, error) { + return r.MatchSearch(v), nil + }), + "exists": typical.UnaryFunction[types.ResourceWithLabels](func(value string) (bool, error) { + return value != "", nil + }), + }, + GetUnknownIdentifier: func(env types.ResourceWithLabels, fields []string) (any, error) { + if fields[0] == ResourceIdentifier { + if f, err := predicate.GetFieldByTag(env, teleport.JSON, fields[1:]); err == nil { + return f, nil + } } - val, ok := r.GetLabel(key) - if ok { - return label{key: key, value: val}, nil - } - return label{}, nil + identifier := strings.Join(fields, ".") + return nil, trace.BadParameter("identifier %s is not defined", identifier) }, }) if err != nil { return nil, trace.Wrap(err) } - return boolPredicateParser{Parser: p}, nil -} - -type label struct { - key, value string + expr, err := parser.Parse(expression) + return expr, trace.Wrap(err) } diff --git a/lib/services/parser_test.go b/lib/services/parser_test.go index 7d1b258cddd43..fbefaea0d9309 100644 --- a/lib/services/parser_test.go +++ b/lib/services/parser_test.go @@ -156,7 +156,7 @@ func TestParserForIdentifierSubcondition(t *testing.T) { }})) } -func TestNewResourceParser(t *testing.T) { +func TestNewResourceExpression(t *testing.T) { t.Parallel() resource, err := types.NewServerWithLabels("test-name", types.KindNode, types.ServerSpecV2{ Hostname: "test-hostname", @@ -172,9 +172,6 @@ func TestNewResourceParser(t *testing.T) { }) require.NoError(t, err) - parser, err := NewResourceParser(resource) - require.NoError(t, err) - t.Run("matching expressions", func(t *testing.T) { t.Parallel() exprs := []string{ @@ -218,7 +215,10 @@ func TestNewResourceParser(t *testing.T) { } for _, expr := range exprs { t.Run(expr, func(t *testing.T) { - match, err := parser.EvalBoolPredicate(expr) + parser, err := NewResourceExpression(expr) + require.NoError(t, err) + + match, err := parser.Evaluate(resource) require.NoError(t, err) require.True(t, match) }) @@ -243,18 +243,19 @@ func TestNewResourceParser(t *testing.T) { } for _, expr := range exprs { t.Run(expr, func(t *testing.T) { - match, err := parser.EvalBoolPredicate(expr) + parser, err := NewResourceExpression(expr) + require.NoError(t, err) + + match, err := parser.Evaluate(resource) require.NoError(t, err) require.False(t, match) }) } }) - t.Run("error in expressions", func(t *testing.T) { + t.Run("fail to parse", func(t *testing.T) { t.Parallel() exprs := []string{ - `name.toomanyfield`, - `labels.env.toomanyfield`, `!name`, `name ==`, `name &`, @@ -267,7 +268,6 @@ func TestNewResourceParser(t *testing.T) { `|`, `&`, `.`, - `equals(resource.incorrect.selector, "_")`, `equals(invalidIdentifier)`, `equals(labels.env)`, `equals(labels.env, "too", "many")`, @@ -284,7 +284,26 @@ func TestNewResourceParser(t *testing.T) { } for _, expr := range exprs { t.Run(expr, func(t *testing.T) { - match, err := parser.EvalBoolPredicate(expr) + expression, err := NewResourceExpression(expr) + require.Error(t, err) + require.Nil(t, expression) + }) + } + }) + + t.Run("fail to evaluate", func(t *testing.T) { + t.Parallel() + exprs := []string{ + `name.toomanyfield`, + `labels.env.toomanyfield`, + `equals(resource.incorrect.selector, "_")`, + } + for _, expr := range exprs { + t.Run(expr, func(t *testing.T) { + parser, err := NewResourceExpression(expr) + require.NoError(t, err) + + match, err := parser.Evaluate(resource) require.Error(t, err) require.False(t, match) }) @@ -292,7 +311,7 @@ func TestNewResourceParser(t *testing.T) { }) } -func TestResourceParser_NameIdentifier(t *testing.T) { +func TestResourceExpression_NameIdentifier(t *testing.T) { t.Parallel() // Server resource should use hostname when using name identifier. @@ -301,9 +320,10 @@ func TestResourceParser_NameIdentifier(t *testing.T) { }, nil) require.NoError(t, err) - parser, err := NewResourceParser(server) + parser, err := NewResourceExpression(`name == "server-hostname"`) require.NoError(t, err) - match, err := parser.EvalBoolPredicate(`name == "server-hostname"`) + + match, err := parser.Evaluate(server) require.NoError(t, err) require.True(t, match) @@ -313,9 +333,10 @@ func TestResourceParser_NameIdentifier(t *testing.T) { }) require.NoError(t, err) - parser, err = NewResourceParser(desktop) + parser, err = NewResourceExpression(`name == "desktop-name"`) require.NoError(t, err) - match, err = parser.EvalBoolPredicate(`name == "desktop-name"`) + + match, err = parser.Evaluate(desktop) require.NoError(t, err) require.True(t, match) } diff --git a/lib/utils/typical/parser.go b/lib/utils/typical/parser.go index 8791bac77b675..f7c1a228cc0a3 100644 --- a/lib/utils/typical/parser.go +++ b/lib/utils/typical/parser.go @@ -655,6 +655,57 @@ func (e unaryVariadicFuncExpr[TEnv, TVarArgs, TResult]) Evaluate(env TEnv) (TRes return res, nil } +type unaryVariadicFunctionWithEnv[TEnv, TVarArgs, TResult any] struct { + impl func(TEnv, ...TVarArgs) (TResult, error) +} + +// UnaryVariadicFunctionWithEnv returns a definition for a function that can be called +// with any number of arguments with a single type. The [impl] will +// be called with the evaluation env as the first argument, followed by the +// actual arguments passed in the expression. +func UnaryVariadicFunctionWithEnv[TEnv, TVarArgs, TResult any](impl func(TEnv, ...TVarArgs) (TResult, error)) Function { + return unaryVariadicFunctionWithEnv[TEnv, TVarArgs, TResult]{impl} +} + +func (f unaryVariadicFunctionWithEnv[TEnv, TVarArgs, TResult]) buildExpression(name string, args ...any) (any, error) { + varArgExprs := make([]Expression[TEnv, TVarArgs], len(args)) + for i, arg := range args { + argExpr, err := coerce[TEnv, TVarArgs](arg) + if err != nil { + return nil, trace.Wrap(err, "parsing argument %d to function (%s)", i+1, name) + } + varArgExprs[i] = argExpr + } + return unaryVariadicFuncWithEnvExpr[TEnv, TVarArgs, TResult]{ + name: name, + impl: f.impl, + varArgExprs: varArgExprs, + }, nil +} + +type unaryVariadicFuncWithEnvExpr[TEnv, TVarArgs, TResult any] struct { + name string + impl func(TEnv, ...TVarArgs) (TResult, error) + varArgExprs []Expression[TEnv, TVarArgs] +} + +func (e unaryVariadicFuncWithEnvExpr[TEnv, TVarArgs, TResult]) Evaluate(env TEnv) (TResult, error) { + var nul TResult + varArgs := make([]TVarArgs, len(e.varArgExprs)) + for i, argExpr := range e.varArgExprs { + arg, err := argExpr.Evaluate(env) + if err != nil { + return nul, trace.Wrap(err, "evaluating argument %d to function (%s)", i+1, e.name) + } + varArgs[i] = arg + } + res, err := e.impl(env, varArgs...) + if err != nil { + return nul, trace.Wrap(err, "evaluating function (%s)", e.name) + } + return res, nil +} + type binaryVariadicFunction[TEnv, TArg1, TVarArgs, TResult any] struct { impl func(TArg1, ...TVarArgs) (TResult, error) }