mirror of
https://github.com/SigNoz/signoz.git
synced 2026-07-14 02:20:32 +01:00
Compare commits
8 Commits
worktree-h
...
platform-p
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
422c9588c4 | ||
|
|
5c6e8b16f9 | ||
|
|
168658b4c3 | ||
|
|
9be93a308a | ||
|
|
042e1fb4f0 | ||
|
|
1798abfc6b | ||
|
|
f155f71051 | ||
|
|
4559a78752 |
@@ -24405,9 +24405,17 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- VIEWER
|
||||
- logs:read
|
||||
- traces:read
|
||||
- metrics:read
|
||||
- audit-logs:read
|
||||
- meter-metrics:read
|
||||
- tokenizer:
|
||||
- VIEWER
|
||||
- logs:read
|
||||
- traces:read
|
||||
- metrics:read
|
||||
- audit-logs:read
|
||||
- meter-metrics:read
|
||||
summary: Query range
|
||||
tags:
|
||||
- querier
|
||||
@@ -24474,9 +24482,17 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- VIEWER
|
||||
- logs:read
|
||||
- traces:read
|
||||
- metrics:read
|
||||
- audit-logs:read
|
||||
- meter-metrics:read
|
||||
- tokenizer:
|
||||
- VIEWER
|
||||
- logs:read
|
||||
- traces:read
|
||||
- metrics:read
|
||||
- audit-logs:read
|
||||
- meter-metrics:read
|
||||
summary: Query range preview
|
||||
tags:
|
||||
- querier
|
||||
|
||||
@@ -223,17 +223,12 @@ func (provider *provider) Update(ctx context.Context, orgID valuer.UUID, updated
|
||||
return err
|
||||
}
|
||||
|
||||
existingGroups := authtypes.MustNewTransactionGroupsFromTuples(existingTuples)
|
||||
additions, deletions := existingGroups.Diff(updatedRole.TransactionGroups)
|
||||
additionTuples, err := authtypes.NewTuplesFromTransactionGroups(existingRole.Name, orgID, additions)
|
||||
desiredTuples, err := authtypes.NewTuplesFromTransactionGroups(existingRole.Name, orgID, updatedRole.TransactionGroups)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
deletionTuples, err := authtypes.NewTuplesFromTransactionGroups(existingRole.Name, orgID, deletions)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
additionTuples, deletionTuples := authtypes.DiffTuples(existingTuples, desiredTuples)
|
||||
|
||||
err = provider.Write(ctx, additionTuples, deletionTuples)
|
||||
if err != nil {
|
||||
|
||||
@@ -4,13 +4,26 @@ import (
|
||||
"net/http"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/http/handler"
|
||||
"github.com/SigNoz/signoz/pkg/querybuilder"
|
||||
"github.com/SigNoz/signoz/pkg/types"
|
||||
"github.com/SigNoz/signoz/pkg/types/authtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/coretypes"
|
||||
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
|
||||
func telemetryReadScopes() []string {
|
||||
return []string{
|
||||
coretypes.ResourceTelemetryResourceLogs.Scope(coretypes.VerbRead),
|
||||
coretypes.ResourceTelemetryResourceTraces.Scope(coretypes.VerbRead),
|
||||
coretypes.ResourceTelemetryResourceMetrics.Scope(coretypes.VerbRead),
|
||||
coretypes.ResourceTelemetryResourceAuditLogs.Scope(coretypes.VerbRead),
|
||||
coretypes.ResourceTelemetryResourceMeterMetrics.Scope(coretypes.VerbRead),
|
||||
}
|
||||
}
|
||||
|
||||
func (provider *provider) addQuerierRoutes(router *mux.Router) error {
|
||||
if err := router.Handle("/api/v5/query_range", handler.New(provider.authzMiddleware.ViewAccess(provider.querierHandler.QueryRange), handler.OpenAPIDef{
|
||||
if err := router.Handle("/api/v5/query_range", handler.New(provider.authzMiddleware.CheckResources(provider.querierHandler.QueryRange, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName, authtypes.SigNozViewerRoleName), handler.OpenAPIDef{
|
||||
ID: "QueryRangeV5",
|
||||
Tags: []string{"querier"},
|
||||
Summary: "Query range",
|
||||
@@ -446,12 +459,17 @@ func (provider *provider) addQuerierRoutes(router *mux.Router) error {
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest},
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
|
||||
})).Methods(http.MethodPost).GetError(); err != nil {
|
||||
SecuritySchemes: newScopedSecuritySchemes(telemetryReadScopes()),
|
||||
}, handler.WithResourceDefs(handler.TelemetryResourceDef{
|
||||
Verb: coretypes.VerbRead,
|
||||
Category: coretypes.ActionCategoryDataAccess,
|
||||
Selector: querybuilder.TelemetrySelector,
|
||||
Resources: querybuilder.QueryRangeResources,
|
||||
}))).Methods(http.MethodPost).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v5/query_range/preview", handler.New(provider.authzMiddleware.ViewAccess(provider.querierHandler.QueryRangePreview), handler.OpenAPIDef{
|
||||
if err := router.Handle("/api/v5/query_range/preview", handler.New(provider.authzMiddleware.CheckResources(provider.querierHandler.QueryRangePreview, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName, authtypes.SigNozViewerRoleName), handler.OpenAPIDef{
|
||||
ID: "QueryRangePreviewV5",
|
||||
Tags: []string{"querier"},
|
||||
Summary: "Query range preview",
|
||||
@@ -463,8 +481,13 @@ func (provider *provider) addQuerierRoutes(router *mux.Router) error {
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest},
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
|
||||
})).Methods(http.MethodPost).GetError(); err != nil {
|
||||
SecuritySchemes: newScopedSecuritySchemes(telemetryReadScopes()),
|
||||
}, handler.WithResourceDefs(handler.TelemetryResourceDef{
|
||||
Verb: coretypes.VerbRead,
|
||||
Category: coretypes.ActionCategoryDataAccess,
|
||||
Selector: querybuilder.TelemetrySelector,
|
||||
Resources: querybuilder.QueryRangeResources,
|
||||
}))).Methods(http.MethodPost).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
package handler
|
||||
|
||||
import "github.com/SigNoz/signoz/pkg/types/coretypes"
|
||||
import (
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/types/coretypes"
|
||||
)
|
||||
|
||||
type ResourceDef interface {
|
||||
// resolveRequest is unexported to seal the interface. It returns a slice so a
|
||||
@@ -97,3 +100,31 @@ func (def AttachDetachParentChildResourceDef) resolveRequest(ec coretypes.Extrac
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
type TelemetryResourceDef struct {
|
||||
Verb coretypes.Verb
|
||||
Category coretypes.ActionCategory
|
||||
Selector coretypes.SelectorFunc
|
||||
Resources coretypes.ResourceExtractor
|
||||
}
|
||||
|
||||
func (def TelemetryResourceDef) resolveRequest(ec coretypes.ExtractorContext) []coretypes.ResolvedResource {
|
||||
refs, err := def.Resources(ec)
|
||||
if err != nil {
|
||||
return []coretypes.ResolvedResource{coretypes.NewResolvedResourceWithError(def.Verb, def.Category, err)}
|
||||
}
|
||||
if len(refs) == 0 {
|
||||
return []coretypes.ResolvedResource{coretypes.NewResolvedResourceWithError(
|
||||
def.Verb,
|
||||
def.Category,
|
||||
errors.NewInvalidInputf(errors.CodeInvalidInput, "request resolved to no resources"),
|
||||
)}
|
||||
}
|
||||
|
||||
resolved := make([]coretypes.ResolvedResource, 0, len(refs))
|
||||
for _, ref := range refs {
|
||||
resolved = append(resolved, coretypes.NewResolvedResourceWithID(def.Verb, def.Category, ref.Resource, ref.ID, def.Selector))
|
||||
}
|
||||
|
||||
return resolved
|
||||
}
|
||||
|
||||
@@ -118,6 +118,10 @@ func (middleware *Audit) emitAuditEvent(req *http.Request, writer responseCaptur
|
||||
extractorCtx := coretypes.ExtractorContext{Request: req, ResponseBody: writer.BodyBytes()}
|
||||
|
||||
for _, resource := range resolved {
|
||||
if err := resource.Err(); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
resource.ResolveResponse(extractorCtx)
|
||||
verb, category := resource.Verb(), resource.Category()
|
||||
|
||||
|
||||
219
pkg/querybuilder/query_range_resources.go
Normal file
219
pkg/querybuilder/query_range_resources.go
Normal file
@@ -0,0 +1,219 @@
|
||||
package querybuilder
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/types/coretypes"
|
||||
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
var TelemetrySelector coretypes.SelectorFunc = func(_ context.Context, resource coretypes.Resource, id string, _ valuer.UUID) ([]coretypes.Selector, error) {
|
||||
values := make([]string, 0)
|
||||
if id != "" {
|
||||
if err := json.Unmarshal([]byte(id), &values); err != nil {
|
||||
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid telemetry resource id %q", id)
|
||||
}
|
||||
}
|
||||
|
||||
values = append(values, coretypes.WildCardSelectorString)
|
||||
|
||||
selectors := make([]coretypes.Selector, 0, len(values))
|
||||
for _, value := range values {
|
||||
selector, err := resource.Type().Selector(value)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
selectors = append(selectors, selector)
|
||||
}
|
||||
|
||||
return selectors, nil
|
||||
}
|
||||
|
||||
func QueryRangeResources(ec coretypes.ExtractorContext) ([]coretypes.ResourceWithID, error) {
|
||||
queries := gjson.GetBytes(ec.RequestBody, "compositeQuery.queries")
|
||||
if !queries.IsArray() || len(queries.Array()) == 0 {
|
||||
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "composite query has no queries")
|
||||
}
|
||||
|
||||
variables, err := queryRangeVariables(ec.RequestBody)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
refs := make([]coretypes.ResourceWithID, 0, len(queries.Array()))
|
||||
seen := make(map[string]struct{})
|
||||
for _, query := range queries.Array() {
|
||||
queryRefs, err := resourcesForQuery(query, variables)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, ref := range queryRefs {
|
||||
key := ref.Resource.Kind().String() + ":" + ref.ID
|
||||
if _, ok := seen[key]; ok {
|
||||
continue
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
refs = append(refs, ref)
|
||||
}
|
||||
}
|
||||
|
||||
return refs, nil
|
||||
}
|
||||
|
||||
func queryRangeVariables(body []byte) (map[string]qbtypes.VariableItem, error) {
|
||||
raw := gjson.GetBytes(body, "variables")
|
||||
if !raw.Exists() {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
variables := make(map[string]qbtypes.VariableItem)
|
||||
if err := json.Unmarshal([]byte(raw.Raw), &variables); err != nil {
|
||||
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid variables in query range request")
|
||||
}
|
||||
|
||||
return variables, nil
|
||||
}
|
||||
|
||||
func resourcesForQuery(query gjson.Result, variables map[string]qbtypes.VariableItem) ([]coretypes.ResourceWithID, error) {
|
||||
queryType := query.Get("type").String()
|
||||
|
||||
switch queryType {
|
||||
case "builder_query", "builder_sub_query":
|
||||
return resourcesForBuilderQuery(query.Get("spec"), variables)
|
||||
case "builder_trace_operator":
|
||||
return []coretypes.ResourceWithID{{Resource: coretypes.ResourceTelemetryResourceTraces}}, nil
|
||||
case "promql":
|
||||
return []coretypes.ResourceWithID{{Resource: coretypes.ResourceTelemetryResourceMetrics}}, nil
|
||||
case "clickhouse_sql":
|
||||
return []coretypes.ResourceWithID{
|
||||
{Resource: coretypes.ResourceTelemetryResourceLogs},
|
||||
{Resource: coretypes.ResourceTelemetryResourceTraces},
|
||||
{Resource: coretypes.ResourceTelemetryResourceMetrics},
|
||||
}, nil
|
||||
case "builder_formula", "builder_join":
|
||||
return nil, nil
|
||||
default:
|
||||
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "unsupported query type %q", queryType)
|
||||
}
|
||||
}
|
||||
|
||||
func resourcesForBuilderQuery(spec gjson.Result, variables map[string]qbtypes.VariableItem) ([]coretypes.ResourceWithID, error) {
|
||||
resource, err := builderQueryResource(spec)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ids, err := serviceResourceIDs(spec.Get("filter.expression").String(), variables)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
refs := make([]coretypes.ResourceWithID, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
refs = append(refs, coretypes.ResourceWithID{Resource: resource, ID: id})
|
||||
}
|
||||
|
||||
return refs, nil
|
||||
}
|
||||
|
||||
func builderQueryResource(spec gjson.Result) (coretypes.Resource, error) {
|
||||
source := spec.Get("source").String()
|
||||
|
||||
switch spec.Get("signal").String() {
|
||||
case telemetrytypes.SignalTraces.StringValue():
|
||||
return coretypes.ResourceTelemetryResourceTraces, nil
|
||||
case telemetrytypes.SignalLogs.StringValue():
|
||||
if source == telemetrytypes.SourceAudit.StringValue() {
|
||||
return coretypes.ResourceTelemetryResourceAuditLogs, nil
|
||||
}
|
||||
return coretypes.ResourceTelemetryResourceLogs, nil
|
||||
case telemetrytypes.SignalMetrics.StringValue():
|
||||
if source == telemetrytypes.SourceMeter.StringValue() {
|
||||
return coretypes.ResourceTelemetryResourceMeterMetrics, nil
|
||||
}
|
||||
return coretypes.ResourceTelemetryResourceMetrics, nil
|
||||
default:
|
||||
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "unsupported signal %q", spec.Get("signal").String())
|
||||
}
|
||||
}
|
||||
|
||||
func serviceResourceIDs(expression string, variables map[string]qbtypes.VariableItem) ([]string, error) {
|
||||
if strings.TrimSpace(expression) == "" {
|
||||
return []string{""}, nil
|
||||
}
|
||||
|
||||
normalized, err := NormalizeWhereClause(expression, variables)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
anyOf := make([]string, 0)
|
||||
ids := make([]string, 0)
|
||||
for _, condition := range normalized.Conditions {
|
||||
if !condition.TopLevel || !isServiceNameKey(condition.Key) {
|
||||
continue
|
||||
}
|
||||
|
||||
switch condition.Operator {
|
||||
case "=":
|
||||
anyOf = append(anyOf, condition.Values[0])
|
||||
case "IN":
|
||||
for _, value := range condition.Values {
|
||||
id, err := serviceResourceID([]string{value})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ids = append(ids, id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(anyOf) > 0 {
|
||||
id, err := serviceResourceID(anyOf)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ids = append(ids, id)
|
||||
}
|
||||
|
||||
if len(ids) == 0 {
|
||||
return []string{""}, nil
|
||||
}
|
||||
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
func serviceResourceID(values []string) (string, error) {
|
||||
sort.Strings(values)
|
||||
deduped := values[:0]
|
||||
for index, value := range values {
|
||||
if index > 0 && value == values[index-1] {
|
||||
continue
|
||||
}
|
||||
deduped = append(deduped, value)
|
||||
}
|
||||
|
||||
encoded, err := json.Marshal(deduped)
|
||||
if err != nil {
|
||||
return "", errors.NewInvalidInputf(errors.CodeInvalidInput, "failed to encode telemetry resource id")
|
||||
}
|
||||
|
||||
return string(encoded), nil
|
||||
}
|
||||
|
||||
func isServiceNameKey(keyText string) bool {
|
||||
fieldKey := telemetrytypes.GetFieldKeyFromKeyText(keyText)
|
||||
if fieldKey.Name != "service.name" {
|
||||
return false
|
||||
}
|
||||
|
||||
return fieldKey.FieldContext == telemetrytypes.FieldContextUnspecified || fieldKey.FieldContext == telemetrytypes.FieldContextResource
|
||||
}
|
||||
166
pkg/querybuilder/query_range_resources_test.go
Normal file
166
pkg/querybuilder/query_range_resources_test.go
Normal file
@@ -0,0 +1,166 @@
|
||||
package querybuilder
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/types/coretypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func builderQueryBody(signal, filterExpression string) string {
|
||||
return `{"compositeQuery":{"queries":[{"type":"builder_query","spec":{"signal":"` + signal + `","filter":{"expression":"` + filterExpression + `"}}}]}}`
|
||||
}
|
||||
|
||||
func TestQueryRangeResources(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
body string
|
||||
expected []coretypes.ResourceWithID
|
||||
}{
|
||||
{
|
||||
name: "top level service equality",
|
||||
body: builderQueryBody("logs", "service.name = 'checkout' AND status = 500"),
|
||||
expected: []coretypes.ResourceWithID{
|
||||
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: `["checkout"]`},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "resource prefixed service key",
|
||||
body: builderQueryBody("traces", "resource.service.name = 'checkout'"),
|
||||
expected: []coretypes.ResourceWithID{
|
||||
{Resource: coretypes.ResourceTelemetryResourceTraces, ID: `["checkout"]`},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "in atom requires every value",
|
||||
body: builderQueryBody("logs", "service.name IN ('b', 'a')"),
|
||||
expected: []coretypes.ResourceWithID{
|
||||
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: `["a"]`},
|
||||
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: `["b"]`},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "multiple equality atoms are alternatives",
|
||||
body: builderQueryBody("logs", "service.name = 'b' AND service.name = 'a'"),
|
||||
expected: []coretypes.ResourceWithID{
|
||||
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: `["a","b"]`},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "no filter expression",
|
||||
body: `{"compositeQuery":{"queries":[{"type":"builder_query","spec":{"signal":"logs"}}]}}`,
|
||||
expected: []coretypes.ResourceWithID{
|
||||
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: ""},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "service atom under or does not qualify",
|
||||
body: builderQueryBody("logs", "service.name = 'a' OR status = 500"),
|
||||
expected: []coretypes.ResourceWithID{
|
||||
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: ""},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "negated service atom does not qualify",
|
||||
body: builderQueryBody("logs", "NOT service.name = 'a'"),
|
||||
expected: []coretypes.ResourceWithID{
|
||||
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: ""},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "service inequality does not qualify",
|
||||
body: builderQueryBody("logs", "service.name != 'a'"),
|
||||
expected: []coretypes.ResourceWithID{
|
||||
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: ""},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "audit source maps to audit logs resource",
|
||||
body: `{"compositeQuery":{"queries":[{"type":"builder_query","spec":{"signal":"logs","source":"audit","filter":{"expression":"service.name = 'a'"}}}]}}`,
|
||||
expected: []coretypes.ResourceWithID{
|
||||
{Resource: coretypes.ResourceTelemetryResourceAuditLogs, ID: `["a"]`},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "promql is wildcard only",
|
||||
body: `{"compositeQuery":{"queries":[{"type":"promql","spec":{"query":"up"}}]}}`,
|
||||
expected: []coretypes.ResourceWithID{
|
||||
{Resource: coretypes.ResourceTelemetryResourceMetrics, ID: ""},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "clickhouse sql covers all signals",
|
||||
body: `{"compositeQuery":{"queries":[{"type":"clickhouse_sql","spec":{"query":"SELECT 1"}}]}}`,
|
||||
expected: []coretypes.ResourceWithID{
|
||||
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: ""},
|
||||
{Resource: coretypes.ResourceTelemetryResourceTraces, ID: ""},
|
||||
{Resource: coretypes.ResourceTelemetryResourceMetrics, ID: ""},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "formula produces no resources",
|
||||
body: `{"compositeQuery":{"queries":[{"type":"builder_formula","spec":{"expression":"A/B"}}]}}`,
|
||||
expected: []coretypes.ResourceWithID{},
|
||||
},
|
||||
{
|
||||
name: "variable substitution qualifies",
|
||||
body: `{"variables":{"svc":{"value":"checkout"}},"compositeQuery":{"queries":[{"type":"builder_query","spec":{"signal":"logs","filter":{"expression":"service.name = $svc"}}}]}}`,
|
||||
expected: []coretypes.ResourceWithID{
|
||||
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: `["checkout"]`},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "duplicate queries dedupe",
|
||||
body: `{"compositeQuery":{"queries":[{"type":"builder_query","spec":{"signal":"logs","filter":{"expression":"service.name = 'a'"}}},{"type":"builder_query","spec":{"signal":"logs","filter":{"expression":"service.name='a'"}}}]}}`,
|
||||
expected: []coretypes.ResourceWithID{
|
||||
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: `["a"]`},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
refs, err := QueryRangeResources(coretypes.ExtractorContext{RequestBody: []byte(testCase.body)})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, testCase.expected, refs)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueryRangeResourcesErrors(t *testing.T) {
|
||||
bodies := []string{
|
||||
`{"compositeQuery":{"queries":[]}}`,
|
||||
`{}`,
|
||||
builderQueryBody("logs", "service.name = "),
|
||||
`{"compositeQuery":{"queries":[{"type":"builder_query","spec":{"signal":"unknown"}}]}}`,
|
||||
`{"compositeQuery":{"queries":[{"type":"unknown_type"}]}}`,
|
||||
}
|
||||
|
||||
for _, body := range bodies {
|
||||
_, err := QueryRangeResources(coretypes.ExtractorContext{RequestBody: []byte(body)})
|
||||
assert.Error(t, err, "body %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTelemetrySelector(t *testing.T) {
|
||||
orgID := valuer.GenerateUUID()
|
||||
|
||||
selectors, err := TelemetrySelector(context.Background(), coretypes.ResourceTelemetryResourceLogs, `["a","b"]`, orgID)
|
||||
require.NoError(t, err)
|
||||
values := make([]string, 0, len(selectors))
|
||||
for _, selector := range selectors {
|
||||
values = append(values, selector.String())
|
||||
}
|
||||
assert.Equal(t, []string{"a", "b", "*"}, values)
|
||||
|
||||
selectors, err = TelemetrySelector(context.Background(), coretypes.ResourceTelemetryResourceLogs, "", orgID)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, selectors, 1)
|
||||
assert.Equal(t, "*", selectors[0].String())
|
||||
|
||||
_, err = TelemetrySelector(context.Background(), coretypes.ResourceTelemetryResourceLogs, "not-json", orgID)
|
||||
assert.Error(t, err)
|
||||
}
|
||||
558
pkg/querybuilder/where_clause_normalizer.go
Normal file
558
pkg/querybuilder/where_clause_normalizer.go
Normal file
@@ -0,0 +1,558 @@
|
||||
package querybuilder
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
grammar "github.com/SigNoz/signoz/pkg/parser/filterquery/grammar"
|
||||
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
"github.com/antlr4-go/antlr/v4"
|
||||
)
|
||||
|
||||
const WhereClauseOperatorFullText = "FULLTEXT"
|
||||
|
||||
type NormalizedWhereClause struct {
|
||||
Expression string
|
||||
Conditions []WhereClauseCondition
|
||||
}
|
||||
|
||||
type WhereClauseCondition struct {
|
||||
Key string
|
||||
Operator string
|
||||
Values []string
|
||||
Negated bool
|
||||
TopLevel bool
|
||||
}
|
||||
|
||||
type joinKind int
|
||||
|
||||
const (
|
||||
joinKindNone joinKind = iota
|
||||
joinKindAnd
|
||||
joinKindOr
|
||||
)
|
||||
|
||||
type normalizedPart struct {
|
||||
text string
|
||||
join joinKind
|
||||
skipped bool
|
||||
}
|
||||
|
||||
type normalizedValue struct {
|
||||
text string
|
||||
raw string
|
||||
}
|
||||
|
||||
type whereClauseNormalizer struct {
|
||||
variables map[string]qbtypes.VariableItem
|
||||
conditions []WhereClauseCondition
|
||||
negated bool
|
||||
orDepth int
|
||||
errors []string
|
||||
}
|
||||
|
||||
func NormalizeWhereClause(expression string, variables map[string]qbtypes.VariableItem) (*NormalizedWhereClause, error) {
|
||||
input := antlr.NewInputStream(expression)
|
||||
lexer := grammar.NewFilterQueryLexer(input)
|
||||
|
||||
lexerErrorListener := NewErrorListener()
|
||||
lexer.RemoveErrorListeners()
|
||||
lexer.AddErrorListener(lexerErrorListener)
|
||||
|
||||
tokens := antlr.NewCommonTokenStream(lexer, 0)
|
||||
parser := grammar.NewFilterQueryParser(tokens)
|
||||
|
||||
parserErrorListener := NewErrorListener()
|
||||
parser.RemoveErrorListeners()
|
||||
parser.AddErrorListener(parserErrorListener)
|
||||
|
||||
tree := parser.Query()
|
||||
|
||||
syntaxErrors := append(lexerErrorListener.SyntaxErrors, parserErrorListener.SyntaxErrors...)
|
||||
if len(syntaxErrors) > 0 {
|
||||
combinedErrors := errors.Newf(
|
||||
errors.TypeInvalidInput,
|
||||
errors.CodeInvalidInput,
|
||||
"Found %d syntax errors while parsing the filter expression.",
|
||||
len(syntaxErrors),
|
||||
)
|
||||
additionals := make([]string, 0, len(syntaxErrors))
|
||||
for _, syntaxError := range syntaxErrors {
|
||||
if syntaxError.Error() != "" {
|
||||
additionals = append(additionals, syntaxError.Error())
|
||||
}
|
||||
}
|
||||
|
||||
return nil, combinedErrors.WithAdditional(additionals...).WithUrl(searchTroubleshootingGuideURL)
|
||||
}
|
||||
|
||||
visitor := &whereClauseNormalizer{
|
||||
variables: variables,
|
||||
conditions: make([]WhereClauseCondition, 0),
|
||||
}
|
||||
part := visitor.visitQuery(tree)
|
||||
|
||||
if len(visitor.errors) > 0 {
|
||||
combinedErrors := errors.Newf(
|
||||
errors.TypeInvalidInput,
|
||||
errors.CodeInvalidInput,
|
||||
"Found %d errors while parsing the filter expression.",
|
||||
len(visitor.errors),
|
||||
)
|
||||
return nil, combinedErrors.WithAdditional(visitor.errors...).WithUrl(searchTroubleshootingGuideURL)
|
||||
}
|
||||
|
||||
if part.skipped {
|
||||
return &NormalizedWhereClause{Expression: "", Conditions: make([]WhereClauseCondition, 0)}, nil
|
||||
}
|
||||
|
||||
sort.Slice(visitor.conditions, func(i, j int) bool {
|
||||
return visitor.conditions[i].sortKey() < visitor.conditions[j].sortKey()
|
||||
})
|
||||
|
||||
return &NormalizedWhereClause{Expression: part.text, Conditions: visitor.conditions}, nil
|
||||
}
|
||||
|
||||
func (condition WhereClauseCondition) sortKey() string {
|
||||
return condition.Key + "|" + condition.Operator + "|" + strings.Join(condition.Values, ",") + "|" + strconv.FormatBool(condition.Negated) + "|" + strconv.FormatBool(condition.TopLevel)
|
||||
}
|
||||
|
||||
func (visitor *whereClauseNormalizer) visitQuery(ctx grammar.IQueryContext) normalizedPart {
|
||||
if ctx.Expression() == nil {
|
||||
return normalizedPart{skipped: true}
|
||||
}
|
||||
|
||||
return visitor.visitOrExpression(ctx.Expression().OrExpression())
|
||||
}
|
||||
|
||||
func (visitor *whereClauseNormalizer) visitOrExpression(ctx grammar.IOrExpressionContext) normalizedPart {
|
||||
andExpressions := ctx.AllAndExpression()
|
||||
if len(andExpressions) > 1 {
|
||||
visitor.orDepth++
|
||||
defer func() { visitor.orDepth-- }()
|
||||
}
|
||||
|
||||
parts := make([]normalizedPart, 0, len(andExpressions))
|
||||
for _, andExpression := range andExpressions {
|
||||
part := visitor.visitAndExpression(andExpression)
|
||||
if part.skipped {
|
||||
continue
|
||||
}
|
||||
parts = append(parts, part)
|
||||
}
|
||||
|
||||
if len(parts) == 0 {
|
||||
return normalizedPart{skipped: true}
|
||||
}
|
||||
|
||||
parts = sortAndDedupeNormalizedParts(parts)
|
||||
if len(parts) == 1 {
|
||||
return parts[0]
|
||||
}
|
||||
|
||||
texts := make([]string, len(parts))
|
||||
for index, part := range parts {
|
||||
texts[index] = part.text
|
||||
}
|
||||
|
||||
return normalizedPart{text: strings.Join(texts, " OR "), join: joinKindOr}
|
||||
}
|
||||
|
||||
func (visitor *whereClauseNormalizer) visitAndExpression(ctx grammar.IAndExpressionContext) normalizedPart {
|
||||
unaryExpressions := ctx.AllUnaryExpression()
|
||||
parts := make([]normalizedPart, 0, len(unaryExpressions))
|
||||
for _, unaryExpression := range unaryExpressions {
|
||||
part := visitor.visitUnaryExpression(unaryExpression)
|
||||
if part.skipped {
|
||||
continue
|
||||
}
|
||||
if part.join == joinKindOr {
|
||||
part = normalizedPart{text: "(" + part.text + ")", join: joinKindNone}
|
||||
}
|
||||
parts = append(parts, part)
|
||||
}
|
||||
|
||||
if len(parts) == 0 {
|
||||
return normalizedPart{skipped: true}
|
||||
}
|
||||
|
||||
parts = sortAndDedupeNormalizedParts(parts)
|
||||
if len(parts) == 1 {
|
||||
return parts[0]
|
||||
}
|
||||
|
||||
texts := make([]string, len(parts))
|
||||
for index, part := range parts {
|
||||
texts[index] = part.text
|
||||
}
|
||||
|
||||
return normalizedPart{text: strings.Join(texts, " AND "), join: joinKindAnd}
|
||||
}
|
||||
|
||||
func (visitor *whereClauseNormalizer) visitUnaryExpression(ctx grammar.IUnaryExpressionContext) normalizedPart {
|
||||
negated := ctx.NOT() != nil
|
||||
if negated {
|
||||
visitor.negated = !visitor.negated
|
||||
}
|
||||
|
||||
part := visitor.visitPrimary(ctx.Primary())
|
||||
|
||||
if negated {
|
||||
visitor.negated = !visitor.negated
|
||||
|
||||
if part.skipped {
|
||||
return part
|
||||
}
|
||||
if part.join != joinKindNone {
|
||||
return normalizedPart{text: "NOT (" + part.text + ")", join: joinKindNone}
|
||||
}
|
||||
return normalizedPart{text: "NOT " + part.text, join: joinKindNone}
|
||||
}
|
||||
|
||||
return part
|
||||
}
|
||||
|
||||
func (visitor *whereClauseNormalizer) visitPrimary(ctx grammar.IPrimaryContext) normalizedPart {
|
||||
if ctx.OrExpression() != nil {
|
||||
return visitor.visitOrExpression(ctx.OrExpression())
|
||||
}
|
||||
if ctx.Comparison() != nil {
|
||||
return visitor.visitComparison(ctx.Comparison())
|
||||
}
|
||||
if ctx.FunctionCall() != nil {
|
||||
return normalizedPart{text: visitor.visitFunctionCall(ctx.FunctionCall())}
|
||||
}
|
||||
if ctx.FullText() != nil {
|
||||
return normalizedPart{text: visitor.visitFullText(ctx.FullText())}
|
||||
}
|
||||
if ctx.Key() != nil {
|
||||
return normalizedPart{text: visitor.fullTextTerm(ctx.Key().GetText())}
|
||||
}
|
||||
if ctx.Value() != nil {
|
||||
value := visitor.normalizeValue(ctx.Value())
|
||||
return normalizedPart{text: visitor.fullTextTerm(value.raw)}
|
||||
}
|
||||
|
||||
return normalizedPart{skipped: true}
|
||||
}
|
||||
|
||||
func (visitor *whereClauseNormalizer) visitComparison(ctx grammar.IComparisonContext) normalizedPart {
|
||||
key := normalizeKeyText(ctx.Key().GetText())
|
||||
|
||||
if ctx.EXISTS() != nil {
|
||||
operator := "EXISTS"
|
||||
if ctx.NOT() != nil {
|
||||
operator = "NOT EXISTS"
|
||||
}
|
||||
visitor.appendCondition(key, operator, nil)
|
||||
return normalizedPart{text: key + " " + operator}
|
||||
}
|
||||
|
||||
if ctx.InClause() != nil {
|
||||
return visitor.visitInComparison(key, "IN", visitor.visitInValues(ctx.InClause().ValueList(), ctx.InClause().Value()))
|
||||
}
|
||||
|
||||
if ctx.NotInClause() != nil {
|
||||
return visitor.visitInComparison(key, "NOT IN", visitor.visitInValues(ctx.NotInClause().ValueList(), ctx.NotInClause().Value()))
|
||||
}
|
||||
|
||||
if ctx.BETWEEN() != nil {
|
||||
operator := "BETWEEN"
|
||||
if ctx.NOT() != nil {
|
||||
operator = "NOT BETWEEN"
|
||||
}
|
||||
|
||||
values := ctx.AllValue()
|
||||
low := visitor.normalizeValue(values[0])
|
||||
high := visitor.normalizeValue(values[1])
|
||||
visitor.appendCondition(key, operator, []string{low.raw, high.raw})
|
||||
return normalizedPart{text: key + " " + operator + " " + low.text + " AND " + high.text}
|
||||
}
|
||||
|
||||
operator := ""
|
||||
switch {
|
||||
case ctx.EQUALS() != nil:
|
||||
operator = "="
|
||||
case ctx.NOT_EQUALS() != nil, ctx.NEQ() != nil:
|
||||
operator = "!="
|
||||
case ctx.LT() != nil:
|
||||
operator = "<"
|
||||
case ctx.LE() != nil:
|
||||
operator = "<="
|
||||
case ctx.GT() != nil:
|
||||
operator = ">"
|
||||
case ctx.GE() != nil:
|
||||
operator = ">="
|
||||
case ctx.LIKE() != nil:
|
||||
operator = "LIKE"
|
||||
case ctx.ILIKE() != nil:
|
||||
operator = "ILIKE"
|
||||
case ctx.REGEXP() != nil:
|
||||
operator = "REGEXP"
|
||||
case ctx.CONTAINS() != nil:
|
||||
operator = "CONTAINS"
|
||||
}
|
||||
if ctx.NOT() != nil {
|
||||
operator = "NOT " + operator
|
||||
}
|
||||
|
||||
value, skipped := visitor.substituteScalarVariable(visitor.normalizeValue(ctx.AllValue()[0]))
|
||||
if skipped {
|
||||
return normalizedPart{skipped: true}
|
||||
}
|
||||
|
||||
visitor.appendCondition(key, operator, []string{value.raw})
|
||||
return normalizedPart{text: key + " " + operator + " " + value.text}
|
||||
}
|
||||
|
||||
func (visitor *whereClauseNormalizer) visitInComparison(key, operator string, values []normalizedValue) normalizedPart {
|
||||
values, skipped := visitor.substituteListVariable(values)
|
||||
if skipped {
|
||||
return normalizedPart{skipped: true}
|
||||
}
|
||||
|
||||
sort.Slice(values, func(i, j int) bool { return values[i].text < values[j].text })
|
||||
|
||||
texts := make([]string, 0, len(values))
|
||||
raws := make([]string, 0, len(values))
|
||||
for index, value := range values {
|
||||
if index > 0 && value.text == values[index-1].text {
|
||||
continue
|
||||
}
|
||||
texts = append(texts, value.text)
|
||||
raws = append(raws, value.raw)
|
||||
}
|
||||
|
||||
visitor.appendCondition(key, operator, raws)
|
||||
return normalizedPart{text: key + " " + operator + " (" + strings.Join(texts, ", ") + ")"}
|
||||
}
|
||||
|
||||
func (visitor *whereClauseNormalizer) visitInValues(valueList grammar.IValueListContext, value grammar.IValueContext) []normalizedValue {
|
||||
values := make([]normalizedValue, 0)
|
||||
if valueList != nil {
|
||||
for _, valueCtx := range valueList.AllValue() {
|
||||
values = append(values, visitor.normalizeValue(valueCtx))
|
||||
}
|
||||
return values
|
||||
}
|
||||
|
||||
return append(values, visitor.normalizeValue(value))
|
||||
}
|
||||
|
||||
func (visitor *whereClauseNormalizer) visitFunctionCall(ctx grammar.IFunctionCallContext) string {
|
||||
functionName := ""
|
||||
switch {
|
||||
case ctx.HAS() != nil:
|
||||
functionName = "has"
|
||||
case ctx.HASANY() != nil:
|
||||
functionName = "hasAny"
|
||||
case ctx.HASALL() != nil:
|
||||
functionName = "hasAll"
|
||||
case ctx.HASTOKEN() != nil:
|
||||
functionName = "hasToken"
|
||||
}
|
||||
|
||||
key := ""
|
||||
texts := make([]string, 0)
|
||||
raws := make([]string, 0)
|
||||
for index, param := range ctx.FunctionParamList().AllFunctionParam() {
|
||||
switch {
|
||||
case param.Key() != nil:
|
||||
keyText := normalizeKeyText(param.Key().GetText())
|
||||
if index == 0 {
|
||||
key = keyText
|
||||
} else {
|
||||
raws = append(raws, keyText)
|
||||
}
|
||||
texts = append(texts, keyText)
|
||||
case param.Value() != nil:
|
||||
value := visitor.normalizeValue(param.Value())
|
||||
texts = append(texts, value.text)
|
||||
raws = append(raws, value.raw)
|
||||
case param.Array() != nil:
|
||||
arrayText, arrayRaws := visitor.visitArray(param.Array())
|
||||
texts = append(texts, arrayText)
|
||||
raws = append(raws, arrayRaws...)
|
||||
}
|
||||
}
|
||||
|
||||
visitor.appendCondition(key, functionName, raws)
|
||||
return functionName + "(" + strings.Join(texts, ", ") + ")"
|
||||
}
|
||||
|
||||
func (visitor *whereClauseNormalizer) visitArray(ctx grammar.IArrayContext) (string, []string) {
|
||||
texts := make([]string, 0)
|
||||
raws := make([]string, 0)
|
||||
for _, valueCtx := range ctx.ValueList().AllValue() {
|
||||
value := visitor.normalizeValue(valueCtx)
|
||||
texts = append(texts, value.text)
|
||||
raws = append(raws, value.raw)
|
||||
}
|
||||
|
||||
return "[" + strings.Join(texts, ", ") + "]", raws
|
||||
}
|
||||
|
||||
func (visitor *whereClauseNormalizer) visitFullText(ctx grammar.IFullTextContext) string {
|
||||
if ctx.QUOTED_TEXT() != nil {
|
||||
return visitor.fullTextTerm(trimQuotes(ctx.QUOTED_TEXT().GetText()))
|
||||
}
|
||||
|
||||
return visitor.fullTextTerm(ctx.FREETEXT().GetText())
|
||||
}
|
||||
|
||||
func (visitor *whereClauseNormalizer) fullTextTerm(term string) string {
|
||||
visitor.appendCondition("", WhereClauseOperatorFullText, []string{term})
|
||||
return quoteValue(term)
|
||||
}
|
||||
|
||||
func (visitor *whereClauseNormalizer) normalizeValue(ctx grammar.IValueContext) normalizedValue {
|
||||
switch {
|
||||
case ctx.QUOTED_TEXT() != nil:
|
||||
raw := trimQuotes(ctx.QUOTED_TEXT().GetText())
|
||||
return normalizedValue{text: quoteValue(raw), raw: raw}
|
||||
case ctx.NUMBER() != nil:
|
||||
text := ctx.NUMBER().GetText()
|
||||
return normalizedValue{text: text, raw: text}
|
||||
case ctx.BOOL() != nil:
|
||||
text := strings.ToLower(ctx.BOOL().GetText())
|
||||
return normalizedValue{text: text, raw: text}
|
||||
default:
|
||||
raw := ctx.KEY().GetText()
|
||||
if strings.HasPrefix(raw, "$") {
|
||||
return normalizedValue{text: raw, raw: raw}
|
||||
}
|
||||
return normalizedValue{text: quoteValue(raw), raw: raw}
|
||||
}
|
||||
}
|
||||
|
||||
func (visitor *whereClauseNormalizer) appendCondition(key, operator string, values []string) {
|
||||
if values == nil {
|
||||
values = make([]string, 0)
|
||||
}
|
||||
|
||||
visitor.conditions = append(visitor.conditions, WhereClauseCondition{
|
||||
Key: key,
|
||||
Operator: operator,
|
||||
Values: values,
|
||||
Negated: visitor.negated,
|
||||
TopLevel: visitor.orDepth == 0 && !visitor.negated,
|
||||
})
|
||||
}
|
||||
|
||||
func (visitor *whereClauseNormalizer) substituteScalarVariable(value normalizedValue) (normalizedValue, bool) {
|
||||
variableItem, ok := visitor.resolveVariable(value.raw)
|
||||
if !ok {
|
||||
return value, false
|
||||
}
|
||||
|
||||
if skipped := visitor.errIfSkippedOrEmpty(variableItem, value.raw); skipped {
|
||||
return normalizedValue{}, true
|
||||
}
|
||||
|
||||
switch variableValues := variableItem.Value.(type) {
|
||||
case []any:
|
||||
return formatVariableValue(variableValues[0]), false
|
||||
case any:
|
||||
return formatVariableValue(variableValues), false
|
||||
}
|
||||
|
||||
return value, false
|
||||
}
|
||||
|
||||
func (visitor *whereClauseNormalizer) substituteListVariable(values []normalizedValue) ([]normalizedValue, bool) {
|
||||
if len(values) != 1 {
|
||||
return values, false
|
||||
}
|
||||
|
||||
variableItem, ok := visitor.resolveVariable(values[0].raw)
|
||||
if !ok {
|
||||
return values, false
|
||||
}
|
||||
|
||||
if skipped := visitor.errIfSkippedOrEmpty(variableItem, values[0].raw); skipped {
|
||||
return nil, true
|
||||
}
|
||||
|
||||
switch variableValues := variableItem.Value.(type) {
|
||||
case []any:
|
||||
substituted := make([]normalizedValue, 0, len(variableValues))
|
||||
for _, variableValue := range variableValues {
|
||||
substituted = append(substituted, formatVariableValue(variableValue))
|
||||
}
|
||||
return substituted, false
|
||||
case any:
|
||||
return []normalizedValue{formatVariableValue(variableValues)}, false
|
||||
}
|
||||
|
||||
return values, false
|
||||
}
|
||||
|
||||
func (visitor *whereClauseNormalizer) errIfSkippedOrEmpty(variableItem qbtypes.VariableItem, raw string) bool {
|
||||
if variableItem.Type == qbtypes.DynamicVariableType {
|
||||
if allValue, ok := variableItem.Value.(string); ok && allValue == "__all__" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
if variableValues, ok := variableItem.Value.([]any); ok && len(variableValues) == 0 {
|
||||
visitor.errors = append(visitor.errors, fmt.Sprintf("malformed request payload: variable `%s` used in expression has an empty list value", strings.TrimPrefix(raw, "$")))
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func (visitor *whereClauseNormalizer) resolveVariable(raw string) (qbtypes.VariableItem, bool) {
|
||||
if len(visitor.variables) == 0 {
|
||||
return qbtypes.VariableItem{}, false
|
||||
}
|
||||
|
||||
variableItem, ok := visitor.variables[raw]
|
||||
if !ok && len(raw) > 0 {
|
||||
variableItem, ok = visitor.variables[raw[1:]]
|
||||
}
|
||||
|
||||
return variableItem, ok
|
||||
}
|
||||
|
||||
func formatVariableValue(value any) normalizedValue {
|
||||
switch typed := value.(type) {
|
||||
case string:
|
||||
return normalizedValue{text: quoteValue(typed), raw: typed}
|
||||
case bool:
|
||||
text := strconv.FormatBool(typed)
|
||||
return normalizedValue{text: text, raw: text}
|
||||
default:
|
||||
text := fmt.Sprintf("%v", typed)
|
||||
return normalizedValue{text: text, raw: text}
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeKeyText(keyText string) string {
|
||||
fieldKey := telemetrytypes.GetFieldKeyFromKeyText(keyText)
|
||||
return telemetrytypes.TelemetryFieldKeyToText(&fieldKey)
|
||||
}
|
||||
|
||||
func quoteValue(value string) string {
|
||||
escaped := strings.ReplaceAll(value, `\`, `\\`)
|
||||
escaped = strings.ReplaceAll(escaped, `'`, `\'`)
|
||||
return "'" + escaped + "'"
|
||||
}
|
||||
|
||||
func sortAndDedupeNormalizedParts(parts []normalizedPart) []normalizedPart {
|
||||
sort.Slice(parts, func(i, j int) bool { return parts[i].text < parts[j].text })
|
||||
|
||||
deduped := parts[:0]
|
||||
for index, part := range parts {
|
||||
if index > 0 && part.text == parts[index-1].text {
|
||||
continue
|
||||
}
|
||||
deduped = append(deduped, part)
|
||||
}
|
||||
|
||||
return deduped
|
||||
}
|
||||
421
pkg/querybuilder/where_clause_normalizer_test.go
Normal file
421
pkg/querybuilder/where_clause_normalizer_test.go
Normal file
@@ -0,0 +1,421 @@
|
||||
package querybuilder
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestNormalizeWhereClauseEquivalenceClasses(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
expressions []string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "spacing and keyword case",
|
||||
expressions: []string{
|
||||
"service.name = 'frontend' AND status = 200",
|
||||
"service.name='frontend' and status=200",
|
||||
"service.name = 'frontend' AND status = 200",
|
||||
"service.name = frontend AND status = 200",
|
||||
},
|
||||
expected: "service.name = 'frontend' AND status = 200",
|
||||
},
|
||||
{
|
||||
name: "operand order",
|
||||
expressions: []string{
|
||||
"a = 1 AND b = 2",
|
||||
"b = 2 AND a = 1",
|
||||
},
|
||||
expected: "a = 1 AND b = 2",
|
||||
},
|
||||
{
|
||||
name: "implicit and explicit AND",
|
||||
expressions: []string{
|
||||
"a = 1 b = 2",
|
||||
"a = 1 AND b = 2",
|
||||
},
|
||||
expected: "a = 1 AND b = 2",
|
||||
},
|
||||
{
|
||||
name: "quote styles",
|
||||
expressions: []string{
|
||||
`a = "frontend"`,
|
||||
"a = 'frontend'",
|
||||
},
|
||||
expected: "a = 'frontend'",
|
||||
},
|
||||
{
|
||||
name: "redundant parentheses",
|
||||
expressions: []string{
|
||||
"(a = 1)",
|
||||
"a = 1",
|
||||
"((a = 1))",
|
||||
},
|
||||
expected: "a = 1",
|
||||
},
|
||||
{
|
||||
name: "in clause forms and value order",
|
||||
expressions: []string{
|
||||
"a IN (1, 2)",
|
||||
"a IN [2, 1]",
|
||||
"a in (2, 1, 1)",
|
||||
},
|
||||
expected: "a IN (1, 2)",
|
||||
},
|
||||
{
|
||||
name: "single value in",
|
||||
expressions: []string{
|
||||
"a IN 1",
|
||||
"a IN (1)",
|
||||
"a IN [1]",
|
||||
},
|
||||
expected: "a IN (1)",
|
||||
},
|
||||
{
|
||||
name: "operator aliases",
|
||||
expressions: []string{
|
||||
"a == 1",
|
||||
"a = 1",
|
||||
},
|
||||
expected: "a = 1",
|
||||
},
|
||||
{
|
||||
name: "not equals aliases",
|
||||
expressions: []string{
|
||||
"a <> 1",
|
||||
"a != 1",
|
||||
},
|
||||
expected: "a != 1",
|
||||
},
|
||||
{
|
||||
name: "duplicate siblings",
|
||||
expressions: []string{
|
||||
"a = 1 AND a = 1",
|
||||
"a = 1",
|
||||
},
|
||||
expected: "a = 1",
|
||||
},
|
||||
{
|
||||
name: "grouped or under and",
|
||||
expressions: []string{
|
||||
"a = 1 AND (b = 2 OR c = 3)",
|
||||
"(c = 3 OR b = 2) AND a = 1",
|
||||
},
|
||||
expected: "(b = 2 OR c = 3) AND a = 1",
|
||||
},
|
||||
{
|
||||
name: "exists spellings",
|
||||
expressions: []string{
|
||||
"service.name EXISTS",
|
||||
"service.name exists",
|
||||
"service.name EXIST",
|
||||
},
|
||||
expected: "service.name EXISTS",
|
||||
},
|
||||
{
|
||||
name: "contains spellings",
|
||||
expressions: []string{
|
||||
"body CONTAINS 'error'",
|
||||
"body contain 'error'",
|
||||
},
|
||||
expected: "body CONTAINS 'error'",
|
||||
},
|
||||
{
|
||||
name: "full text term forms",
|
||||
expressions: []string{
|
||||
`"panic"`,
|
||||
"'panic'",
|
||||
"panic",
|
||||
},
|
||||
expected: "'panic'",
|
||||
},
|
||||
{
|
||||
name: "not without parens",
|
||||
expressions: []string{
|
||||
"NOT a = 1",
|
||||
"not (a = 1)",
|
||||
},
|
||||
expected: "NOT a = 1",
|
||||
},
|
||||
{
|
||||
name: "not over grouped or",
|
||||
expressions: []string{
|
||||
"NOT (b = 2 OR a = 1)",
|
||||
"not (a = 1 or b = 2)",
|
||||
},
|
||||
expected: "NOT (a = 1 OR b = 2)",
|
||||
},
|
||||
{
|
||||
name: "function name case",
|
||||
expressions: []string{
|
||||
"HAS(tags, 'x')",
|
||||
"has(tags, 'x')",
|
||||
},
|
||||
expected: "has(tags, 'x')",
|
||||
},
|
||||
{
|
||||
name: "boolean case",
|
||||
expressions: []string{
|
||||
"a = TRUE",
|
||||
"a = true",
|
||||
},
|
||||
expected: "a = true",
|
||||
},
|
||||
{
|
||||
name: "between",
|
||||
expressions: []string{
|
||||
"duration BETWEEN 1 AND 10",
|
||||
"duration between 1 and 10",
|
||||
},
|
||||
expected: "duration BETWEEN 1 AND 10",
|
||||
},
|
||||
{
|
||||
name: "not in",
|
||||
expressions: []string{
|
||||
"a NOT IN (2, 1)",
|
||||
"a not in [1, 2]",
|
||||
},
|
||||
expected: "a NOT IN (1, 2)",
|
||||
},
|
||||
{
|
||||
name: "key with datatype annotation",
|
||||
expressions: []string{
|
||||
"resource.service.name:string = 'x'",
|
||||
},
|
||||
expected: "resource.service.name:string = 'x'",
|
||||
},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
for _, expression := range testCase.expressions {
|
||||
canonical, err := NormalizeWhereClause(expression, nil)
|
||||
require.NoError(t, err, "expression %q", expression)
|
||||
assert.Equal(t, testCase.expected, canonical.Expression, "expression %q", expression)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeWhereClauseNonEquivalence(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
left string
|
||||
right string
|
||||
}{
|
||||
{name: "different values", left: "a = 1", right: "a = 2"},
|
||||
{name: "different keys", left: "a = 1", right: "b = 1"},
|
||||
{name: "different operators", left: "a = 1", right: "a != 1"},
|
||||
{name: "no semantic rewrite of not", left: "NOT a = 1", right: "a != 1"},
|
||||
{name: "number literals as authored", left: "a = 1.0", right: "a = 1"},
|
||||
{name: "between bounds are ordered", left: "a BETWEEN 1 AND 10", right: "a BETWEEN 10 AND 1"},
|
||||
{name: "function params are ordered", left: "has(tags, 'x')", right: "has('x', tags)"},
|
||||
{name: "and vs or", left: "a = 1 AND b = 2", right: "a = 1 OR b = 2"},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
left, err := NormalizeWhereClause(testCase.left, nil)
|
||||
require.NoError(t, err)
|
||||
right, err := NormalizeWhereClause(testCase.right, nil)
|
||||
require.NoError(t, err)
|
||||
assert.NotEqual(t, left.Expression, right.Expression)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeWhereClauseAtoms(t *testing.T) {
|
||||
canonical, err := NormalizeWhereClause("NOT (a = 1 OR b IN ('y', 'x')) AND service.name EXISTS AND hasAny(tags, ['p', 'q']) AND \"panic\"", nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
expected := []WhereClauseCondition{
|
||||
{Key: "a", Operator: "=", Values: []string{"1"}, Negated: true},
|
||||
{Key: "b", Operator: "IN", Values: []string{"x", "y"}, Negated: true},
|
||||
{Key: "service.name", Operator: "EXISTS", Values: []string{}, Negated: false, TopLevel: true},
|
||||
{Key: "tags", Operator: "hasAny", Values: []string{"p", "q"}, Negated: false, TopLevel: true},
|
||||
{Key: "", Operator: WhereClauseOperatorFullText, Values: []string{"panic"}, Negated: false, TopLevel: true},
|
||||
}
|
||||
assert.ElementsMatch(t, expected, canonical.Conditions)
|
||||
}
|
||||
|
||||
func TestNormalizeWhereClauseTopLevel(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
expression string
|
||||
expected map[string]bool
|
||||
}{
|
||||
{
|
||||
name: "and siblings are top level",
|
||||
expression: "service.name = 'a' AND status = 500",
|
||||
expected: map[string]bool{"service.name": true, "status": true},
|
||||
},
|
||||
{
|
||||
name: "or branches are not top level",
|
||||
expression: "service.name = 'a' OR status = 500",
|
||||
expected: map[string]bool{"service.name": false, "status": false},
|
||||
},
|
||||
{
|
||||
name: "and sibling stays top level next to a grouped or",
|
||||
expression: "service.name = 'a' AND (x = 1 OR y = 2)",
|
||||
expected: map[string]bool{"service.name": true, "x": false, "y": false},
|
||||
},
|
||||
{
|
||||
name: "parenthesized pure and group stays top level",
|
||||
expression: "(service.name = 'a' AND b = 2) AND c = 3",
|
||||
expected: map[string]bool{"service.name": true, "b": true, "c": true},
|
||||
},
|
||||
{
|
||||
name: "negated condition is not top level",
|
||||
expression: "NOT service.name = 'a' AND status = 500",
|
||||
expected: map[string]bool{"service.name": false, "status": true},
|
||||
},
|
||||
{
|
||||
name: "double negation restores top level",
|
||||
expression: "NOT (NOT (service.name = 'a'))",
|
||||
expected: map[string]bool{"service.name": true},
|
||||
},
|
||||
{
|
||||
name: "in condition under and is top level",
|
||||
expression: "service.name IN ('a', 'b') AND x = 1",
|
||||
expected: map[string]bool{"service.name": true, "x": true},
|
||||
},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
normalized, err := NormalizeWhereClause(testCase.expression, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
actual := make(map[string]bool)
|
||||
for _, condition := range normalized.Conditions {
|
||||
actual[condition.Key] = condition.TopLevel
|
||||
}
|
||||
assert.Equal(t, testCase.expected, actual)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeWhereClauseEscaping(t *testing.T) {
|
||||
canonical, err := NormalizeWhereClause(`a = "it's fine"`, nil)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, `a = 'it\'s fine'`, canonical.Expression)
|
||||
require.Len(t, canonical.Conditions, 1)
|
||||
assert.Equal(t, []string{"it's fine"}, canonical.Conditions[0].Values)
|
||||
|
||||
equivalent, err := NormalizeWhereClause(canonical.Expression, nil)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, canonical.Expression, equivalent.Expression)
|
||||
assert.Equal(t, canonical.Conditions, equivalent.Conditions)
|
||||
}
|
||||
|
||||
func TestNormalizeWhereClauseSyntaxError(t *testing.T) {
|
||||
_, err := NormalizeWhereClause("a = ", nil)
|
||||
require.Error(t, err)
|
||||
|
||||
_, err = NormalizeWhereClause("AND a = 1", nil)
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestNormalizeWhereClauseIdempotence(t *testing.T) {
|
||||
expressions := []string{
|
||||
"service.name='frontend' and (status = 500 or status=502) not retired k8s.pod.name exists",
|
||||
"a IN [3, 1, 2] AND hasAll(tags, ['a', 'b']) AND body CONTAINS 'x'",
|
||||
"duration BETWEEN 1 AND 10 OR duration > 100",
|
||||
`msg = 'with \'escapes\' and "quotes"'`,
|
||||
}
|
||||
|
||||
for _, expression := range expressions {
|
||||
first, err := NormalizeWhereClause(expression, nil)
|
||||
require.NoError(t, err, "expression %q", expression)
|
||||
|
||||
second, err := NormalizeWhereClause(first.Expression, nil)
|
||||
require.NoError(t, err, "canonical output %q must re-parse", first.Expression)
|
||||
assert.Equal(t, first.Expression, second.Expression, "canonicalization must be idempotent for %q", expression)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeWhereClauseVariables(t *testing.T) {
|
||||
variables := map[string]qbtypes.VariableItem{
|
||||
"service": {Value: "frontend"},
|
||||
"statuses": {Value: []any{float64(502), float64(500)}},
|
||||
"env": {Type: qbtypes.DynamicVariableType, Value: "__all__"},
|
||||
"limit": {Value: float64(100)},
|
||||
}
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
expression string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "scalar substitution",
|
||||
expression: "service.name = $service",
|
||||
expected: "service.name = 'frontend'",
|
||||
},
|
||||
{
|
||||
name: "array substitution in IN is sorted",
|
||||
expression: "status IN $statuses",
|
||||
expected: "status IN (500, 502)",
|
||||
},
|
||||
{
|
||||
name: "numeric substitution",
|
||||
expression: "duration > $limit",
|
||||
expected: "duration > 100",
|
||||
},
|
||||
{
|
||||
name: "dynamic all prunes the condition",
|
||||
expression: "a = 1 AND deployment.environment IN $env",
|
||||
expected: "a = 1",
|
||||
},
|
||||
{
|
||||
name: "unknown variable stays a token",
|
||||
expression: "service.name = $unknown",
|
||||
expected: "service.name = $unknown",
|
||||
},
|
||||
{
|
||||
name: "substituted forms hash-equal to concrete forms",
|
||||
expression: "status IN (502, 500) AND service.name = 'frontend'",
|
||||
expected: "service.name = 'frontend' AND status IN (500, 502)",
|
||||
},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
normalized, err := NormalizeWhereClause(testCase.expression, variables)
|
||||
require.NoError(t, err, "expression %q", testCase.expression)
|
||||
assert.Equal(t, testCase.expected, normalized.Expression)
|
||||
})
|
||||
}
|
||||
|
||||
substituted, err := NormalizeWhereClause("service.name = $service AND status IN $statuses", variables)
|
||||
require.NoError(t, err)
|
||||
concrete, err := NormalizeWhereClause("status IN (500, 502) AND service.name = 'frontend'", nil)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, concrete.Expression, substituted.Expression)
|
||||
assert.Equal(t, concrete.Conditions, substituted.Conditions)
|
||||
}
|
||||
|
||||
func TestNormalizeWhereClauseVariablesFullyPruned(t *testing.T) {
|
||||
variables := map[string]qbtypes.VariableItem{
|
||||
"env": {Type: qbtypes.DynamicVariableType, Value: "__all__"},
|
||||
}
|
||||
|
||||
normalized, err := NormalizeWhereClause("deployment.environment IN $env", variables)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "", normalized.Expression)
|
||||
assert.Empty(t, normalized.Conditions)
|
||||
}
|
||||
|
||||
func TestNormalizeWhereClauseVariablesEmptyList(t *testing.T) {
|
||||
variables := map[string]qbtypes.VariableItem{
|
||||
"statuses": {Value: []any{}},
|
||||
}
|
||||
|
||||
_, err := NormalizeWhereClause("status IN $statuses", variables)
|
||||
require.Error(t, err)
|
||||
}
|
||||
@@ -68,6 +68,38 @@ func NewTuplesFromTransactionGroups(name string, orgID valuer.UUID, transactionG
|
||||
return tuples, nil
|
||||
}
|
||||
|
||||
func DiffTuples(existing, desired []*openfgav1.TupleKey) (additions, deletions []*openfgav1.TupleKey) {
|
||||
key := func(tuple *openfgav1.TupleKey) string {
|
||||
return tuple.GetUser() + "|" + tuple.GetRelation() + "|" + tuple.GetObject()
|
||||
}
|
||||
|
||||
existingSet := make(map[string]struct{}, len(existing))
|
||||
for _, tuple := range existing {
|
||||
existingSet[key(tuple)] = struct{}{}
|
||||
}
|
||||
|
||||
desiredSet := make(map[string]struct{}, len(desired))
|
||||
for _, tuple := range desired {
|
||||
desiredSet[key(tuple)] = struct{}{}
|
||||
}
|
||||
|
||||
additions = make([]*openfgav1.TupleKey, 0)
|
||||
for _, tuple := range desired {
|
||||
if _, ok := existingSet[key(tuple)]; !ok {
|
||||
additions = append(additions, tuple)
|
||||
}
|
||||
}
|
||||
|
||||
deletions = make([]*openfgav1.TupleKey, 0)
|
||||
for _, tuple := range existing {
|
||||
if _, ok := desiredSet[key(tuple)]; !ok {
|
||||
deletions = append(deletions, tuple)
|
||||
}
|
||||
}
|
||||
|
||||
return additions, deletions
|
||||
}
|
||||
|
||||
func MustNewTransactionGroupsFromTuples(tuples []*openfgav1.TupleKey) TransactionGroups {
|
||||
objectsByRelation := make(map[string][]*coretypes.Object)
|
||||
|
||||
|
||||
66
pkg/types/authtypes/tuple_test.go
Normal file
66
pkg/types/authtypes/tuple_test.go
Normal file
@@ -0,0 +1,66 @@
|
||||
package authtypes
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/types/coretypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
openfgav1 "github.com/openfga/api/proto/openfga/v1"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestTelemetryGrantAndCheckObjectsMatch(t *testing.T) {
|
||||
orgID := valuer.GenerateUUID()
|
||||
|
||||
grantGroups := TransactionGroups{
|
||||
{
|
||||
Relation: Relation{Verb: coretypes.VerbRead},
|
||||
ObjectGroup: coretypes.ObjectGroup{
|
||||
Resource: coretypes.ResourceRef{Type: coretypes.TypeTelemetryResource, Kind: coretypes.KindLogs},
|
||||
Selectors: []coretypes.Selector{coretypes.TypeTelemetryResource.MustSelector("checkout")},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
grantTuples, err := NewTuplesFromTransactionGroups("scoped-role", orgID, grantGroups)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, grantTuples, 1)
|
||||
|
||||
checkTuples := NewTuples(
|
||||
coretypes.ResourceTelemetryResourceLogs,
|
||||
"user:organization/"+orgID.StringValue()+"/user/some-user",
|
||||
Relation{Verb: coretypes.VerbRead},
|
||||
[]coretypes.Selector{
|
||||
coretypes.TypeTelemetryResource.MustSelector("checkout"),
|
||||
coretypes.TypeTelemetryResource.MustSelector("payments"),
|
||||
coretypes.TypeTelemetryResource.MustSelector(coretypes.WildCardSelectorString),
|
||||
},
|
||||
orgID,
|
||||
)
|
||||
require.Len(t, checkTuples, 3)
|
||||
|
||||
assert.Equal(t, grantTuples[0].GetObject(), checkTuples[0].GetObject())
|
||||
assert.NotEqual(t, grantTuples[0].GetObject(), checkTuples[1].GetObject())
|
||||
assert.NotContains(t, checkTuples[0].GetObject(), "checkout")
|
||||
assert.Equal(t, "telemetryresource:organization/"+orgID.StringValue()+"/logs/*", checkTuples[2].GetObject())
|
||||
}
|
||||
|
||||
func TestDiffTuples(t *testing.T) {
|
||||
tuple := func(object string) *openfgav1.TupleKey {
|
||||
return &openfgav1.TupleKey{User: "role:organization/o/role/r#assignee", Relation: "read", Object: object}
|
||||
}
|
||||
|
||||
existing := []*openfgav1.TupleKey{tuple("a"), tuple("b")}
|
||||
desired := []*openfgav1.TupleKey{tuple("b"), tuple("c")}
|
||||
|
||||
additions, deletions := DiffTuples(existing, desired)
|
||||
require.Len(t, additions, 1)
|
||||
assert.Equal(t, "c", additions[0].GetObject())
|
||||
require.Len(t, deletions, 1)
|
||||
assert.Equal(t, "a", deletions[0].GetObject())
|
||||
|
||||
additions, deletions = DiffTuples(existing, existing)
|
||||
assert.Empty(t, additions)
|
||||
assert.Empty(t, deletions)
|
||||
}
|
||||
@@ -55,6 +55,13 @@ func OneID(extractor ResourceIDExtractor) ResourceIDsExtractor {
|
||||
}}
|
||||
}
|
||||
|
||||
type ResourceWithID struct {
|
||||
Resource Resource
|
||||
ID string
|
||||
}
|
||||
|
||||
type ResourceExtractor func(ExtractorContext) ([]ResourceWithID, error)
|
||||
|
||||
func PathParam(name string) ResourceIDExtractor {
|
||||
return ResourceIDExtractor{Phase: PhaseRequest, Fn: func(ec ExtractorContext) (string, error) {
|
||||
if ec.Request == nil {
|
||||
|
||||
@@ -23,5 +23,5 @@ var (
|
||||
TypeRole = Type{valuer.NewString("role"), regexp.MustCompile(`^([a-z-]{1,50}|\*)$`), []Verb{VerbAssignee, VerbCreate, VerbList, VerbRead, VerbUpdate, VerbDelete, VerbAttach, VerbDetach}}
|
||||
TypeOrganization = Type{valuer.NewString("organization"), regexp.MustCompile(`^(^[0-9a-f]{8}(?:\-[0-9a-f]{4}){3}-[0-9a-f]{12}$|\*)$`), []Verb{VerbRead, VerbUpdate}}
|
||||
TypeMetaResource = Type{valuer.NewString("metaresource"), regexp.MustCompile(`^(^[0-9a-f]{8}(?:\-[0-9a-f]{4}){3}-[0-9a-f]{12}$|\*)$`), []Verb{VerbCreate, VerbList, VerbRead, VerbUpdate, VerbDelete, VerbAttach, VerbDetach}}
|
||||
TypeTelemetryResource = Type{valuer.NewString("telemetryresource"), regexp.MustCompile(`^\*$`), []Verb{VerbRead}}
|
||||
TypeTelemetryResource = Type{valuer.NewString("telemetryresource"), regexp.MustCompile(`^(\*|\S(.{0,253}\S)?)$`), []Verb{VerbRead}}
|
||||
)
|
||||
|
||||
@@ -30,6 +30,19 @@ func NewResolvedResource(
|
||||
return resolved
|
||||
}
|
||||
|
||||
func NewResolvedResourceWithID(verb Verb, category ActionCategory, resource Resource, id string, selector SelectorFunc) ResolvedResource {
|
||||
resolved := &resolvedResource{verb: verb, category: category, resource: resource, selector: selector}
|
||||
if id != "" {
|
||||
resolved.ids = []string{id}
|
||||
}
|
||||
|
||||
return resolved
|
||||
}
|
||||
|
||||
func NewResolvedResourceWithError(verb Verb, category ActionCategory, err error) ResolvedResource {
|
||||
return &resolvedResource{verb: verb, category: category, err: err}
|
||||
}
|
||||
|
||||
func (resolved *resolvedResource) fill(phase ExtractPhase, ec ExtractorContext) {
|
||||
if !resolved.idExtractor.IsPhase(phase) {
|
||||
return
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
package coretypes
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
|
||||
@@ -26,7 +29,18 @@ func (resourceTelemetryResource *resourceTelemetryResource) Prefix(orgID valuer.
|
||||
}
|
||||
|
||||
func (resourceTelemetryResource *resourceTelemetryResource) Object(orgID valuer.UUID, selector string) string {
|
||||
return resourceTelemetryResource.Prefix(orgID) + "/" + selector
|
||||
if selector == WildCardSelectorString {
|
||||
return resourceTelemetryResource.Prefix(orgID) + "/" + selector
|
||||
}
|
||||
|
||||
return resourceTelemetryResource.Prefix(orgID) + "/" + TelemetrySelectorSegment(selector)
|
||||
}
|
||||
|
||||
// Must stay stable: grant-time and check-time object building both rely on
|
||||
// producing the same segment for the same selector value.
|
||||
func TelemetrySelectorSegment(selector string) string {
|
||||
sum := sha256.Sum256([]byte(selector))
|
||||
return hex.EncodeToString(sum[:16])
|
||||
}
|
||||
|
||||
func (resourceTelemetryResource *resourceTelemetryResource) Scope(verb Verb) string {
|
||||
|
||||
43
pkg/types/coretypes/selector_test.go
Normal file
43
pkg/types/coretypes/selector_test.go
Normal file
@@ -0,0 +1,43 @@
|
||||
package coretypes
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestTelemetryResourceSelectorRegex(t *testing.T) {
|
||||
valid := []string{
|
||||
"*",
|
||||
"a",
|
||||
"checkout-service",
|
||||
"signoz agent",
|
||||
"frontend/us-east-1",
|
||||
"abcdef0123456789abcdef0123456789",
|
||||
strings.Repeat("a", 255),
|
||||
}
|
||||
for _, value := range valid {
|
||||
_, err := TypeTelemetryResource.Selector(value)
|
||||
assert.NoError(t, err, "expected %q to be a valid telemetry selector", value)
|
||||
}
|
||||
|
||||
invalid := []string{
|
||||
"",
|
||||
" ",
|
||||
" leading-space",
|
||||
"trailing-space ",
|
||||
strings.Repeat("a", 256),
|
||||
}
|
||||
for _, value := range invalid {
|
||||
_, err := TypeTelemetryResource.Selector(value)
|
||||
assert.Error(t, err, "expected %q to be rejected as a telemetry selector", value)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTelemetrySelectorSegment(t *testing.T) {
|
||||
segment := TelemetrySelectorSegment("checkout-service")
|
||||
assert.Len(t, segment, 32)
|
||||
assert.Equal(t, segment, TelemetrySelectorSegment("checkout-service"))
|
||||
assert.NotEqual(t, segment, TelemetrySelectorSegment("checkout-service2"))
|
||||
}
|
||||
Reference in New Issue
Block a user