mirror of
https://github.com/SigNoz/signoz.git
synced 2026-07-17 03:40:31 +01:00
Compare commits
15 Commits
issue-4293
...
platform-p
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5668baab47 | ||
|
|
2f2420c363 | ||
|
|
654eebd897 | ||
|
|
e23f4a3b8c | ||
|
|
199092276a | ||
|
|
4f291e0664 | ||
|
|
410eb36fde | ||
|
|
422c9588c4 | ||
|
|
5c6e8b16f9 | ||
|
|
168658b4c3 | ||
|
|
9be93a308a | ||
|
|
042e1fb4f0 | ||
|
|
1798abfc6b | ||
|
|
f155f71051 | ||
|
|
4559a78752 |
1
.github/workflows/integrationci.yaml
vendored
1
.github/workflows/integrationci.yaml
vendored
@@ -54,6 +54,7 @@ jobs:
|
||||
- querierscalar
|
||||
- queriercommon
|
||||
- rawexportdata
|
||||
- querierauthz
|
||||
- role
|
||||
- rootuser
|
||||
- serviceaccount
|
||||
|
||||
@@ -24464,9 +24464,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
|
||||
@@ -24533,9 +24541,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
|
||||
|
||||
@@ -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"
|
||||
"fmt"
|
||||
"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 telemetryGrantKeys = map[string]struct{}{
|
||||
"service.name": {},
|
||||
}
|
||||
|
||||
const telemetryValueSafeBytes = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789._-"
|
||||
|
||||
func EscapeTelemetryValue(value string) string {
|
||||
var escaped strings.Builder
|
||||
for _, character := range []byte(value) {
|
||||
if strings.IndexByte(telemetryValueSafeBytes, character) >= 0 {
|
||||
escaped.WriteByte(character)
|
||||
continue
|
||||
}
|
||||
escaped.WriteString(fmt.Sprintf("%%%02X", character))
|
||||
}
|
||||
|
||||
return escaped.String()
|
||||
}
|
||||
|
||||
func TelemetrySelector(_ context.Context, resource coretypes.Resource, id string, _ valuer.UUID) ([]coretypes.Selector, error) {
|
||||
values := []string{id}
|
||||
segments := strings.Split(id, "/")
|
||||
for level := len(segments) - 1; level >= 1; level-- {
|
||||
value := strings.Join(segments[:level], "/") + "/" + coretypes.WildCardSelectorString
|
||||
if value == id {
|
||||
continue
|
||||
}
|
||||
values = append(values, value)
|
||||
}
|
||||
if id != coretypes.WildCardSelectorString {
|
||||
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) {
|
||||
variables := make(map[string]qbtypes.VariableItem)
|
||||
|
||||
raw := gjson.GetBytes(body, "variables")
|
||||
if !raw.Exists() {
|
||||
return variables, nil
|
||||
}
|
||||
|
||||
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()
|
||||
typeWildcard := queryType + "/" + coretypes.WildCardSelectorString
|
||||
|
||||
switch queryType {
|
||||
case qbtypes.QueryTypeBuilder.StringValue(), qbtypes.QueryTypeSubQuery.StringValue():
|
||||
return resourcesForBuilderQuery(queryType, query.Get("spec"), variables)
|
||||
case qbtypes.QueryTypePromQL.StringValue():
|
||||
return []coretypes.ResourceWithID{{Resource: coretypes.ResourceTelemetryResourceMetrics, ID: typeWildcard}}, nil
|
||||
case qbtypes.QueryTypeClickHouseSQL.StringValue():
|
||||
return []coretypes.ResourceWithID{
|
||||
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: typeWildcard},
|
||||
{Resource: coretypes.ResourceTelemetryResourceTraces, ID: typeWildcard},
|
||||
{Resource: coretypes.ResourceTelemetryResourceMetrics, ID: typeWildcard},
|
||||
}, nil
|
||||
case qbtypes.QueryTypeFormula.StringValue(), qbtypes.QueryTypeJoin.StringValue(), qbtypes.QueryTypeTraceOperator.StringValue():
|
||||
return nil, nil
|
||||
default:
|
||||
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "unsupported query type %q", queryType)
|
||||
}
|
||||
}
|
||||
|
||||
func resourcesForBuilderQuery(queryType string, spec gjson.Result, variables map[string]qbtypes.VariableItem) ([]coretypes.ResourceWithID, error) {
|
||||
resource, err := builderQueryResource(spec)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ids, err := builderQuerySelectors(queryType, 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 builderQuerySelectors(queryType, expression string, variables map[string]qbtypes.VariableItem) ([]string, error) {
|
||||
typeWildcard := queryType + "/" + coretypes.WildCardSelectorString
|
||||
|
||||
if strings.TrimSpace(expression) == "" {
|
||||
return []string{typeWildcard}, nil
|
||||
}
|
||||
|
||||
normalized, err := NormalizeWhereClause(expression, variables)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ids := make([]string, 0)
|
||||
for _, condition := range normalized.Conditions {
|
||||
if !condition.TopLevel {
|
||||
continue
|
||||
}
|
||||
|
||||
key, ok := canonicalTelemetryGrantKey(condition.Key)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
if condition.Operator == "=" || condition.Operator == "IN" {
|
||||
for _, value := range condition.Values {
|
||||
ids = append(ids, queryType+"/"+key+"/"+EscapeTelemetryValue(value))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(ids) == 0 {
|
||||
return []string{typeWildcard}, nil
|
||||
}
|
||||
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
func canonicalTelemetryGrantKey(keyText string) (string, bool) {
|
||||
fieldKey := telemetrytypes.GetFieldKeyFromKeyText(keyText)
|
||||
if fieldKey.FieldContext != telemetrytypes.FieldContextUnspecified && fieldKey.FieldContext != telemetrytypes.FieldContextResource {
|
||||
return "", false
|
||||
}
|
||||
|
||||
if _, ok := telemetryGrantKeys[fieldKey.Name]; !ok {
|
||||
return "", false
|
||||
}
|
||||
|
||||
return fieldKey.Name, true
|
||||
}
|
||||
183
pkg/querybuilder/query_range_resources_test.go
Normal file
183
pkg/querybuilder/query_range_resources_test.go
Normal file
@@ -0,0 +1,183 @@
|
||||
package querybuilder
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"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: "builder_query/service.name/checkout"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "resource prefixed service key",
|
||||
body: builderQueryBody("traces", "resource.service.name = 'checkout'"),
|
||||
expected: []coretypes.ResourceWithID{
|
||||
{Resource: coretypes.ResourceTelemetryResourceTraces, ID: "builder_query/service.name/checkout"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "in atom requires every value",
|
||||
body: builderQueryBody("logs", "service.name IN ('b', 'a')"),
|
||||
expected: []coretypes.ResourceWithID{
|
||||
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/service.name/a"},
|
||||
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/service.name/b"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "multiple equality atoms each require a grant",
|
||||
body: builderQueryBody("logs", "service.name = 'b' AND service.name = 'a'"),
|
||||
expected: []coretypes.ResourceWithID{
|
||||
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/service.name/a"},
|
||||
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/service.name/b"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "no filter expression",
|
||||
body: `{"compositeQuery":{"queries":[{"type":"builder_query","spec":{"signal":"logs"}}]}}`,
|
||||
expected: []coretypes.ResourceWithID{
|
||||
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/*"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "service atom under or does not qualify",
|
||||
body: builderQueryBody("logs", "service.name = 'a' OR status = 500"),
|
||||
expected: []coretypes.ResourceWithID{
|
||||
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/*"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "negated service atom does not qualify",
|
||||
body: builderQueryBody("logs", "NOT service.name = 'a'"),
|
||||
expected: []coretypes.ResourceWithID{
|
||||
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/*"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "service inequality does not qualify",
|
||||
body: builderQueryBody("logs", "service.name != 'a'"),
|
||||
expected: []coretypes.ResourceWithID{
|
||||
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/*"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "unsafe value bytes are escaped",
|
||||
body: builderQueryBody("logs", "service.name = 'check out/2'"),
|
||||
expected: []coretypes.ResourceWithID{
|
||||
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/service.name/check%20out%2F2"},
|
||||
},
|
||||
},
|
||||
{
|
||||
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: "builder_query/service.name/a"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "promql is wildcard only",
|
||||
body: `{"compositeQuery":{"queries":[{"type":"promql","spec":{"query":"up"}}]}}`,
|
||||
expected: []coretypes.ResourceWithID{
|
||||
{Resource: coretypes.ResourceTelemetryResourceMetrics, ID: "promql/*"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "clickhouse sql covers all signals",
|
||||
body: `{"compositeQuery":{"queries":[{"type":"clickhouse_sql","spec":{"query":"SELECT 1"}}]}}`,
|
||||
expected: []coretypes.ResourceWithID{
|
||||
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "clickhouse_sql/*"},
|
||||
{Resource: coretypes.ResourceTelemetryResourceTraces, ID: "clickhouse_sql/*"},
|
||||
{Resource: coretypes.ResourceTelemetryResourceMetrics, ID: "clickhouse_sql/*"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "formula produces no resources",
|
||||
body: `{"compositeQuery":{"queries":[{"type":"builder_formula","spec":{"expression":"A/B"}}]}}`,
|
||||
expected: []coretypes.ResourceWithID{},
|
||||
},
|
||||
{
|
||||
name: "trace operator rides on its referenced queries",
|
||||
body: `{"compositeQuery":{"queries":[{"type":"builder_query","spec":{"name":"A","signal":"traces","disabled":true,"filter":{"expression":"service.name = 'checkout'"}}},{"type":"builder_query","spec":{"name":"B","signal":"traces","disabled":true,"filter":{"expression":"service.name = 'checkout' AND has_error = true"}}},{"type":"builder_trace_operator","spec":{"name":"T1","expression":"A => B","returnSpansFrom":"A"}}]}}`,
|
||||
expected: []coretypes.ResourceWithID{
|
||||
{Resource: coretypes.ResourceTelemetryResourceTraces, ID: "builder_query/service.name/checkout"},
|
||||
},
|
||||
},
|
||||
{
|
||||
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: "builder_query/service.name/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: "builder_query/service.name/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()
|
||||
|
||||
selectorValues := func(id string) []string {
|
||||
selectors, err := TelemetrySelector(context.Background(), coretypes.ResourceTelemetryResourceLogs, id, orgID)
|
||||
require.NoError(t, err)
|
||||
values := make([]string, 0, len(selectors))
|
||||
for _, selector := range selectors {
|
||||
values = append(values, selector.String())
|
||||
}
|
||||
return values
|
||||
}
|
||||
|
||||
assert.Equal(t, []string{"builder_query/service.name/a", "builder_query/service.name/*", "builder_query/*", "*"}, selectorValues("builder_query/service.name/a"))
|
||||
assert.Equal(t, []string{"builder_query/*", "*"}, selectorValues("builder_query/*"))
|
||||
assert.Equal(t, []string{"promql/*", "*"}, selectorValues("promql/*"))
|
||||
|
||||
_, err := TelemetrySelector(context.Background(), coretypes.ResourceTelemetryResourceLogs, strings.Repeat("a", 256), 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)
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -66,7 +66,7 @@ func MustNewObjectFromString(input string) *Object {
|
||||
return &Object{Resource: resource, Selector: typed.MustSelector(orgParts[1])}
|
||||
}
|
||||
|
||||
parts := strings.Split(input, "/")
|
||||
parts := strings.SplitN(input, "/", 4)
|
||||
if len(parts) != 4 {
|
||||
panic(errors.Newf(errors.TypeInternal, errors.CodeInternal, "invalid input format: %s", input))
|
||||
}
|
||||
|
||||
@@ -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(`^(\*|[a-z_]{1,32}(/(\*|[A-Za-z0-9._%-]{1,128})){0,2})$`), []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
|
||||
|
||||
208
tests/integration/tests/querierauthz/01_service_scoping.py
Normal file
208
tests/integration/tests/querierauthz/01_service_scoping.py
Normal file
@@ -0,0 +1,208 @@
|
||||
from collections.abc import Callable
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from http import HTTPStatus
|
||||
|
||||
import pytest
|
||||
import requests
|
||||
from wiremock.resources.mappings import Mapping
|
||||
|
||||
from fixtures import types
|
||||
from fixtures.auth import (
|
||||
USER_ADMIN_EMAIL,
|
||||
USER_ADMIN_PASSWORD,
|
||||
add_license,
|
||||
change_user_role,
|
||||
create_active_user,
|
||||
)
|
||||
from fixtures.logs import Logs
|
||||
from fixtures.querier import build_raw_query, get_column_data_from_response, make_query_request
|
||||
from fixtures.role import transaction_group
|
||||
|
||||
user_password = "password123Z$"
|
||||
scoped_role = "telemetry-scope-svc-a"
|
||||
scoped_email = "scope-svc-a@telemetry.test"
|
||||
|
||||
|
||||
def test_setup(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: types.Operation, # pylint: disable=unused-argument
|
||||
make_http_mocks: Callable[[types.TestContainerDocker, list[Mapping]], None],
|
||||
get_token: Callable[[str, str], str],
|
||||
create_role: Callable[..., str],
|
||||
) -> None:
|
||||
add_license(signoz, make_http_mocks, get_token)
|
||||
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
create_role(
|
||||
admin_token,
|
||||
scoped_role,
|
||||
[
|
||||
transaction_group("read", "telemetryresource", "logs", ["builder_query/service.name/service-a"]),
|
||||
transaction_group("read", "telemetryresource", "traces", ["builder_query/service.name/service-a"]),
|
||||
],
|
||||
)
|
||||
user_id = create_active_user(signoz, admin_token, email=scoped_email, role="VIEWER", password=user_password)
|
||||
change_user_role(signoz, admin_token, user_id, "signoz-viewer", scoped_role)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"selector",
|
||||
[
|
||||
"service.name = 'service-a'", # expression form, not the wire form
|
||||
"builder_query/service.name/check out", # raw space
|
||||
"builder_query/service.name/'quoted'", # quote
|
||||
"builder_query/service.name/a/b/c", # too deep
|
||||
],
|
||||
)
|
||||
def test_invalid_telemetry_selector_rejected(
|
||||
signoz: types.SigNoz,
|
||||
get_token: Callable[[str, str], str],
|
||||
selector: str,
|
||||
) -> None:
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
response = requests.post(
|
||||
signoz.self.host_configs["8080"].get("/api/v1/roles"),
|
||||
json={
|
||||
"name": "telemetry-invalid-selector",
|
||||
"transactionGroups": [transaction_group("read", "telemetryresource", "logs", [selector])],
|
||||
},
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.BAD_REQUEST, response.text
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"expression",
|
||||
[
|
||||
"service.name = 'service-a'",
|
||||
"service.name IN ('service-a')",
|
||||
"resource.service.name = 'service-a'",
|
||||
"service.name = 'service-a' AND severity_text = 'ERROR'",
|
||||
],
|
||||
)
|
||||
def test_allowed(
|
||||
signoz: types.SigNoz,
|
||||
get_token: Callable[[str, str], str],
|
||||
insert_logs: Callable[[list[Logs]], None],
|
||||
expression: str,
|
||||
) -> None:
|
||||
now = datetime.now(tz=UTC)
|
||||
# Seed a service-a log so the resource-attribute key resolves; without any
|
||||
# ingested data the querier rejects the filter with "key not found".
|
||||
insert_logs([Logs(timestamp=now - timedelta(seconds=1), resources={"service.name": "service-a"}, body="service-a-0")])
|
||||
|
||||
response = make_query_request(
|
||||
signoz,
|
||||
get_token(scoped_email, user_password),
|
||||
int((now - timedelta(minutes=10)).timestamp() * 1000),
|
||||
int(now.timestamp() * 1000),
|
||||
[build_raw_query("A", "logs", limit=50, filter_expression=expression)],
|
||||
request_type="raw",
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"expression",
|
||||
[
|
||||
None, # no filter
|
||||
"service.name = 'service-b'",
|
||||
"service.name IN ('service-a', 'service-b')",
|
||||
"service.name = 'service-a' OR severity_text = 'ERROR'",
|
||||
"NOT service.name = 'service-a'",
|
||||
"service.name != 'service-b'",
|
||||
# Same result set as IN ('service-a','service-b'), but the OR spelling is not
|
||||
# yet recognized as a bounded set, so it is denied today. This flips to
|
||||
# allowed-with-both-grants once the where-clause bound evaluation lands.
|
||||
"service.name = 'service-a' OR service.name = 'service-b'",
|
||||
],
|
||||
)
|
||||
def test_denied(
|
||||
signoz: types.SigNoz,
|
||||
get_token: Callable[[str, str], str],
|
||||
expression: str | None,
|
||||
) -> None:
|
||||
now = datetime.now(tz=UTC)
|
||||
response = make_query_request(
|
||||
signoz,
|
||||
get_token(scoped_email, user_password),
|
||||
int((now - timedelta(minutes=10)).timestamp() * 1000),
|
||||
int(now.timestamp() * 1000),
|
||||
[build_raw_query("A", "logs", limit=50, filter_expression=expression)],
|
||||
request_type="raw",
|
||||
)
|
||||
assert response.status_code == HTTPStatus.FORBIDDEN, response.text
|
||||
assert "not authorized" in response.text
|
||||
|
||||
|
||||
def test_denied_message_names_resource(
|
||||
signoz: types.SigNoz,
|
||||
get_token: Callable[[str, str], str],
|
||||
) -> None:
|
||||
now = datetime.now(tz=UTC)
|
||||
response = make_query_request(
|
||||
signoz,
|
||||
get_token(scoped_email, user_password),
|
||||
int((now - timedelta(minutes=10)).timestamp() * 1000),
|
||||
int(now.timestamp() * 1000),
|
||||
[build_raw_query("A", "logs", limit=50, filter_expression="service.name = 'service-b'")],
|
||||
request_type="raw",
|
||||
)
|
||||
assert response.status_code == HTTPStatus.FORBIDDEN, response.text
|
||||
assert "builder_query/service.name/service-b" in response.text
|
||||
|
||||
|
||||
def test_variables_resolve_into_gate(
|
||||
signoz: types.SigNoz,
|
||||
get_token: Callable[[str, str], str],
|
||||
insert_logs: Callable[[list[Logs]], None],
|
||||
) -> None:
|
||||
now = datetime.now(tz=UTC)
|
||||
insert_logs([Logs(timestamp=now - timedelta(seconds=1), resources={"service.name": "service-a"}, body="service-a-0")])
|
||||
start, end = int((now - timedelta(minutes=10)).timestamp() * 1000), int(now.timestamp() * 1000)
|
||||
token = get_token(scoped_email, user_password)
|
||||
|
||||
allowed = make_query_request(
|
||||
signoz,
|
||||
token,
|
||||
start,
|
||||
end,
|
||||
[build_raw_query("A", "logs", limit=50, filter_expression="service.name = $svc")],
|
||||
request_type="raw",
|
||||
variables={"svc": {"value": "service-a"}},
|
||||
)
|
||||
assert allowed.status_code == HTTPStatus.OK, allowed.text
|
||||
|
||||
denied = make_query_request(
|
||||
signoz,
|
||||
token,
|
||||
start,
|
||||
end,
|
||||
[build_raw_query("A", "logs", limit=50, filter_expression="service.name = $svc")],
|
||||
request_type="raw",
|
||||
variables={"svc": {"value": "service-b"}},
|
||||
)
|
||||
assert denied.status_code == HTTPStatus.FORBIDDEN, denied.text
|
||||
|
||||
|
||||
def test_returns_only_scoped_rows(
|
||||
signoz: types.SigNoz,
|
||||
get_token: Callable[[str, str], str],
|
||||
insert_logs: Callable[[list[Logs]], None],
|
||||
) -> None:
|
||||
now = datetime.now(tz=UTC)
|
||||
insert_logs([Logs(timestamp=now - timedelta(seconds=i + 1), resources={"service.name": "service-a"}, body=f"service-a-{i}") for i in range(3)] + [Logs(timestamp=now - timedelta(seconds=i + 1), resources={"service.name": "service-b"}, body=f"service-b-{i}") for i in range(3)])
|
||||
|
||||
response = make_query_request(
|
||||
signoz,
|
||||
get_token(scoped_email, user_password),
|
||||
int((now - timedelta(minutes=10)).timestamp() * 1000),
|
||||
int(now.timestamp() * 1000),
|
||||
[build_raw_query("A", "logs", limit=50, filter_expression="service.name = 'service-a'")],
|
||||
request_type="raw",
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
bodies = get_column_data_from_response(response.json(), "body")
|
||||
assert bodies, "expected rows for service-a"
|
||||
assert all(body.startswith("service-a") for body in bodies), bodies
|
||||
112
tests/integration/tests/querierauthz/02_wildcard_scoping.py
Normal file
112
tests/integration/tests/querierauthz/02_wildcard_scoping.py
Normal file
@@ -0,0 +1,112 @@
|
||||
from collections.abc import Callable
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from http import HTTPStatus
|
||||
|
||||
from fixtures import types
|
||||
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD, change_user_role, create_active_user
|
||||
from fixtures.logs import Logs
|
||||
from fixtures.querier import build_raw_query, get_column_data_from_response, make_query_request
|
||||
from fixtures.role import transaction_group
|
||||
|
||||
user_password = "password123Z$"
|
||||
any_service_role = "telemetry-scope-any-service"
|
||||
any_service_email = "scope-any-service@telemetry.test"
|
||||
builder_all_role = "telemetry-scope-builder-all"
|
||||
builder_all_email = "scope-builder-all@telemetry.test"
|
||||
|
||||
|
||||
def test_setup(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: types.Operation, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
create_role: Callable[..., str],
|
||||
) -> None:
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
create_role(admin_token, any_service_role, [transaction_group("read", "telemetryresource", "logs", ["builder_query/service.name/*"])])
|
||||
any_user = create_active_user(signoz, admin_token, email=any_service_email, role="VIEWER", password=user_password)
|
||||
change_user_role(signoz, admin_token, any_user, "signoz-viewer", any_service_role)
|
||||
|
||||
create_role(admin_token, builder_all_role, [transaction_group("read", "telemetryresource", "logs", ["builder_query/*"])])
|
||||
all_user = create_active_user(signoz, admin_token, email=builder_all_email, role="VIEWER", password=user_password)
|
||||
change_user_role(signoz, admin_token, all_user, "signoz-viewer", builder_all_role)
|
||||
|
||||
|
||||
def test_service_wildcard_allows_any_single_service(
|
||||
signoz: types.SigNoz,
|
||||
get_token: Callable[[str, str], str],
|
||||
insert_logs: Callable[[list[Logs]], None],
|
||||
) -> None:
|
||||
now = datetime.now(tz=UTC)
|
||||
insert_logs(
|
||||
[
|
||||
Logs(timestamp=now - timedelta(seconds=1), resources={"service.name": "service-a"}, body="service-a-0"),
|
||||
Logs(timestamp=now - timedelta(seconds=1), resources={"service.name": "service-b"}, body="service-b-0"),
|
||||
]
|
||||
)
|
||||
start, end = int((now - timedelta(minutes=10)).timestamp() * 1000), int(now.timestamp() * 1000)
|
||||
token = get_token(any_service_email, user_password)
|
||||
|
||||
service_a = make_query_request(signoz, token, start, end, [build_raw_query("A", "logs", limit=50, filter_expression="service.name = 'service-a'")], request_type="raw")
|
||||
assert service_a.status_code == HTTPStatus.OK, service_a.text
|
||||
|
||||
service_b = make_query_request(signoz, token, start, end, [build_raw_query("A", "logs", limit=50, filter_expression="service.name = 'service-b'")], request_type="raw")
|
||||
assert service_b.status_code == HTTPStatus.OK, service_b.text
|
||||
|
||||
|
||||
def test_service_wildcard_denies_unfiltered(
|
||||
signoz: types.SigNoz,
|
||||
get_token: Callable[[str, str], str],
|
||||
) -> None:
|
||||
now = datetime.now(tz=UTC)
|
||||
response = make_query_request(
|
||||
signoz,
|
||||
get_token(any_service_email, user_password),
|
||||
int((now - timedelta(minutes=10)).timestamp() * 1000),
|
||||
int(now.timestamp() * 1000),
|
||||
[build_raw_query("A", "logs", limit=50)],
|
||||
request_type="raw",
|
||||
)
|
||||
assert response.status_code == HTTPStatus.FORBIDDEN, response.text
|
||||
|
||||
|
||||
def test_builder_wildcard_allows_unfiltered(
|
||||
signoz: types.SigNoz,
|
||||
get_token: Callable[[str, str], str],
|
||||
) -> None:
|
||||
now = datetime.now(tz=UTC)
|
||||
response = make_query_request(
|
||||
signoz,
|
||||
get_token(builder_all_email, user_password),
|
||||
int((now - timedelta(minutes=10)).timestamp() * 1000),
|
||||
int(now.timestamp() * 1000),
|
||||
[build_raw_query("A", "logs", limit=50)],
|
||||
request_type="raw",
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
|
||||
|
||||
def test_admin_allows_unfiltered_across_services(
|
||||
signoz: types.SigNoz,
|
||||
get_token: Callable[[str, str], str],
|
||||
insert_logs: Callable[[list[Logs]], None],
|
||||
) -> None:
|
||||
now = datetime.now(tz=UTC)
|
||||
insert_logs(
|
||||
[
|
||||
Logs(timestamp=now - timedelta(seconds=1), resources={"service.name": "service-a"}, body="service-a-0"),
|
||||
Logs(timestamp=now - timedelta(seconds=1), resources={"service.name": "service-b"}, body="service-b-0"),
|
||||
]
|
||||
)
|
||||
|
||||
response = make_query_request(
|
||||
signoz,
|
||||
get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD),
|
||||
int((now - timedelta(minutes=10)).timestamp() * 1000),
|
||||
int(now.timestamp() * 1000),
|
||||
[build_raw_query("A", "logs", limit=50)],
|
||||
request_type="raw",
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
bodies = get_column_data_from_response(response.json(), "body")
|
||||
assert any(body.startswith("service-b") for body in bodies), bodies
|
||||
122
tests/integration/tests/querierauthz/03_query_types.py
Normal file
122
tests/integration/tests/querierauthz/03_query_types.py
Normal file
@@ -0,0 +1,122 @@
|
||||
from collections.abc import Callable
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from http import HTTPStatus
|
||||
|
||||
from fixtures import querier, types
|
||||
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD, change_user_role, create_active_user
|
||||
from fixtures.querier import make_query_request
|
||||
from fixtures.role import transaction_group
|
||||
|
||||
user_password = "password123Z$"
|
||||
chsql_role = "telemetry-scope-chsql"
|
||||
chsql_email = "scope-chsql@telemetry.test"
|
||||
svc_a_role = "telemetry-qt-svc-a"
|
||||
svc_a_email = "qt-svc-a@telemetry.test"
|
||||
|
||||
clickhouse_query = [{"type": "clickhouse_sql", "spec": {"name": "A", "query": "SELECT toFloat64(1.5) AS `__result_0`", "disabled": False}}]
|
||||
promql_query = [{"type": "promql", "spec": {"name": "A", "query": "up", "disabled": False}}]
|
||||
|
||||
|
||||
def test_setup(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: types.Operation, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
create_role: Callable[..., str],
|
||||
) -> None:
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
create_role(
|
||||
admin_token,
|
||||
chsql_role,
|
||||
[
|
||||
transaction_group("read", "telemetryresource", "logs", ["clickhouse_sql/*"]),
|
||||
transaction_group("read", "telemetryresource", "traces", ["clickhouse_sql/*"]),
|
||||
transaction_group("read", "telemetryresource", "metrics", ["clickhouse_sql/*"]),
|
||||
],
|
||||
)
|
||||
chsql_user = create_active_user(signoz, admin_token, email=chsql_email, role="VIEWER", password=user_password)
|
||||
change_user_role(signoz, admin_token, chsql_user, "signoz-viewer", chsql_role)
|
||||
|
||||
create_role(admin_token, svc_a_role, [transaction_group("read", "telemetryresource", "traces", ["builder_query/service.name/service-a"])])
|
||||
svc_a_user = create_active_user(signoz, admin_token, email=svc_a_email, role="VIEWER", password=user_password)
|
||||
change_user_role(signoz, admin_token, svc_a_user, "signoz-viewer", svc_a_role)
|
||||
|
||||
|
||||
def test_clickhouse_sql_requires_chsql_grant(
|
||||
signoz: types.SigNoz,
|
||||
get_token: Callable[[str, str], str],
|
||||
) -> None:
|
||||
now = datetime.now(tz=UTC)
|
||||
start, end = int((now - timedelta(hours=1)).timestamp() * 1000), int(now.timestamp() * 1000)
|
||||
|
||||
granted = make_query_request(signoz, get_token(chsql_email, user_password), start, end, clickhouse_query, request_type=querier.RequestType.SCALAR)
|
||||
assert granted.status_code == HTTPStatus.OK, granted.text
|
||||
|
||||
scoped = make_query_request(signoz, get_token(svc_a_email, user_password), start, end, clickhouse_query, request_type=querier.RequestType.SCALAR)
|
||||
assert scoped.status_code == HTTPStatus.FORBIDDEN, scoped.text
|
||||
|
||||
admin = make_query_request(signoz, get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD), start, end, clickhouse_query, request_type=querier.RequestType.SCALAR)
|
||||
assert admin.status_code == HTTPStatus.OK, admin.text
|
||||
|
||||
|
||||
def test_promql_requires_promql_grant(
|
||||
signoz: types.SigNoz,
|
||||
get_token: Callable[[str, str], str],
|
||||
) -> None:
|
||||
now = datetime.now(tz=UTC)
|
||||
start, end = int((now - timedelta(hours=1)).timestamp() * 1000), int(now.timestamp() * 1000)
|
||||
|
||||
# Neither the chsql grant nor a builder-service grant covers promql.
|
||||
scoped = make_query_request(signoz, get_token(svc_a_email, user_password), start, end, promql_query, request_type=querier.RequestType.TIME_SERIES)
|
||||
assert scoped.status_code == HTTPStatus.FORBIDDEN, scoped.text
|
||||
|
||||
# Admin holds the wildcard; authz passes (the handler may still 2xx/4xx, never 403).
|
||||
admin = make_query_request(signoz, get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD), start, end, promql_query, request_type=querier.RequestType.TIME_SERIES)
|
||||
assert admin.status_code != HTTPStatus.FORBIDDEN, admin.text
|
||||
|
||||
|
||||
def test_trace_operator_rides_on_referenced_queries(
|
||||
signoz: types.SigNoz,
|
||||
get_token: Callable[[str, str], str],
|
||||
) -> None:
|
||||
now = datetime.now(tz=UTC)
|
||||
start, end = int((now - timedelta(minutes=10)).timestamp() * 1000), int(now.timestamp() * 1000)
|
||||
token = get_token(svc_a_email, user_password)
|
||||
|
||||
def operator_queries(b_service: str) -> list[dict]:
|
||||
return [
|
||||
{"type": "builder_query", "spec": {"name": "A", "signal": "traces", "disabled": True, "filter": {"expression": "service.name = 'service-a'"}, "aggregations": [{"expression": "count()"}]}},
|
||||
{"type": "builder_query", "spec": {"name": "B", "signal": "traces", "disabled": True, "filter": {"expression": f"service.name = '{b_service}'"}, "aggregations": [{"expression": "count()"}]}},
|
||||
{"type": "builder_trace_operator", "spec": {"name": "T1", "expression": "A => B", "returnSpansFrom": "A", "disabled": False}},
|
||||
]
|
||||
|
||||
allowed = make_query_request(signoz, token, start, end, operator_queries("service-a"), request_type=querier.RequestType.RAW)
|
||||
assert allowed.status_code == HTTPStatus.OK, allowed.text
|
||||
|
||||
denied = make_query_request(signoz, token, start, end, operator_queries("service-b"), request_type=querier.RequestType.RAW)
|
||||
assert denied.status_code == HTTPStatus.FORBIDDEN, denied.text
|
||||
|
||||
|
||||
def test_formula_rides_on_referenced_queries(
|
||||
signoz: types.SigNoz,
|
||||
get_token: Callable[[str, str], str],
|
||||
) -> None:
|
||||
now = datetime.now(tz=UTC)
|
||||
start, end = int((now - timedelta(minutes=10)).timestamp() * 1000), int(now.timestamp() * 1000)
|
||||
token = get_token(svc_a_email, user_password)
|
||||
|
||||
def formula_queries(b_filtered: bool) -> list[dict]:
|
||||
b_spec = {"name": "B", "signal": "traces", "disabled": True, "aggregations": [{"expression": "count()"}]}
|
||||
if b_filtered:
|
||||
b_spec["filter"] = {"expression": "service.name = 'service-a'"}
|
||||
return [
|
||||
{"type": "builder_query", "spec": {"name": "A", "signal": "traces", "disabled": True, "filter": {"expression": "service.name = 'service-a'"}, "aggregations": [{"expression": "count()"}]}},
|
||||
{"type": "builder_query", "spec": b_spec},
|
||||
{"type": "builder_formula", "spec": {"name": "F1", "expression": "A/B", "disabled": False}},
|
||||
]
|
||||
|
||||
allowed = make_query_request(signoz, token, start, end, formula_queries(b_filtered=True), request_type=querier.RequestType.SCALAR)
|
||||
assert allowed.status_code == HTTPStatus.OK, allowed.text
|
||||
|
||||
denied = make_query_request(signoz, token, start, end, formula_queries(b_filtered=False), request_type=querier.RequestType.SCALAR)
|
||||
assert denied.status_code == HTTPStatus.FORBIDDEN, denied.text
|
||||
62
tests/integration/tests/querierauthz/04_escaping.py
Normal file
62
tests/integration/tests/querierauthz/04_escaping.py
Normal file
@@ -0,0 +1,62 @@
|
||||
from collections.abc import Callable
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from http import HTTPStatus
|
||||
|
||||
from fixtures import types
|
||||
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD, change_user_role, create_active_user
|
||||
from fixtures.logs import Logs
|
||||
from fixtures.querier import build_raw_query, make_query_request
|
||||
from fixtures.role import transaction_group
|
||||
|
||||
user_password = "password123Z$"
|
||||
spacey_role = "telemetry-scope-spacey"
|
||||
spacey_email = "scope-spacey@telemetry.test"
|
||||
# The service name has a space; its canonical selector escapes it to %20.
|
||||
spacey_service = "check out"
|
||||
|
||||
|
||||
def test_setup(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: types.Operation, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
create_role: Callable[..., str],
|
||||
) -> None:
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
create_role(admin_token, spacey_role, [transaction_group("read", "telemetryresource", "logs", ["builder_query/service.name/check%20out"])])
|
||||
user_id = create_active_user(signoz, admin_token, email=spacey_email, role="VIEWER", password=user_password)
|
||||
change_user_role(signoz, admin_token, user_id, "signoz-viewer", spacey_role)
|
||||
|
||||
|
||||
def test_escaped_value_parity_allows_matching_service(
|
||||
signoz: types.SigNoz,
|
||||
get_token: Callable[[str, str], str],
|
||||
insert_logs: Callable[[list[Logs]], None],
|
||||
) -> None:
|
||||
now = datetime.now(tz=UTC)
|
||||
insert_logs([Logs(timestamp=now - timedelta(seconds=1), resources={"service.name": spacey_service}, body="spacey-0")])
|
||||
|
||||
response = make_query_request(
|
||||
signoz,
|
||||
get_token(spacey_email, user_password),
|
||||
int((now - timedelta(minutes=10)).timestamp() * 1000),
|
||||
int(now.timestamp() * 1000),
|
||||
[build_raw_query("A", "logs", limit=50, filter_expression=f"service.name = '{spacey_service}'")],
|
||||
request_type="raw",
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
|
||||
|
||||
def test_escaped_value_denies_other_service(
|
||||
signoz: types.SigNoz,
|
||||
get_token: Callable[[str, str], str],
|
||||
) -> None:
|
||||
now = datetime.now(tz=UTC)
|
||||
response = make_query_request(
|
||||
signoz,
|
||||
get_token(spacey_email, user_password),
|
||||
int((now - timedelta(minutes=10)).timestamp() * 1000),
|
||||
int(now.timestamp() * 1000),
|
||||
[build_raw_query("A", "logs", limit=50, filter_expression="service.name = 'checkout'")],
|
||||
request_type="raw",
|
||||
)
|
||||
assert response.status_code == HTTPStatus.FORBIDDEN, response.text
|
||||
Reference in New Issue
Block a user