Compare commits

...

11 Commits

Author SHA1 Message Date
vikrantgupta25
18a52a39a2 fix(authz): disqualify telemetry gate atoms touched by negation
A grant-key atom authorizes a query only on a NOT-free, OR-free AND
spine. A sticky negationDepth replaces the toggled negated flag in the
top-level check, so a cancelling double negation like
NOT (NOT a AND NOT b) no longer marks its comparisons top-level and
grant a checkout-only role access to frontend data.
2026-10-23 16:14:15 +05:30
Vikrant Gupta
b8951defad Merge branch 'main' into keystone-pod/issues/73 2026-10-23 12:44:01 +05:30
vikrantgupta25
c3fb588e6f refactor(authz): identify a telemetry query by its grant atoms
The resource ID is built from the top-level grant-key atoms found in the
first normalize pass, so a single list expands without re-parsing and
the normalizer no longer needs pins.
2026-10-22 19:03:38 +05:30
vikrantgupta25
b0c3eae3b8 test(authz): expect the query filter in telemetry deny messages 2026-10-22 17:24:35 +05:30
Vikrant Gupta
4fe7b0ad81 Merge branch 'main' into keystone-pod/issues/73 2026-10-22 17:12:16 +05:30
vikrantgupta25
3725772d46 refactor(authz): expand only a single IN list and drop the disjunct cap
Queries carry at most one multi-value list in practice, so the cross
product, its cap and the authz.telemetry.max_query_disjuncts setting go.
Two or more lists keep the query as one resource selectable through the
key wildcards.
2026-10-22 16:28:47 +05:30
vikrantgupta25
17b78a98a2 chore(authz): move the role wizard key dropdown to a follow-up 2026-10-22 15:38:08 +05:30
vikrantgupta25
6e5d295545 fix(authz): keep normalized filters re-parseable and bound the IN expansion safely
A nested NOT now renders with parentheses so a query ID round-trips
through the grammar, repeated conditions collapse to match the
deduplicated expression, and the disjunct cap is checked before each
multiplication.
2026-10-14 00:44:45 +05:30
vikrantgupta25
42beea849c feat(authz): treat a telemetry query as the resource and its atoms as selectors
A builder query is identified by its normalized filter and is selectable
by any top-level grant-key atom, so narrowing a granted query by another
key no longer requires a second grant. An IN is a union: it expands into
one resource per value combination, bounded by
authz.telemetry.max_query_disjuncts.
2026-10-14 00:21:55 +05:30
vikrantgupta25
08d69b1f26 fix(authz): require resource prefix for customer-controlled grant keys
A bare key resolves to the attribute map when metadata has no resource
entry, so service.name and deployment.environment.name must be written
as resource.<key> in grants and queries. signoz.workspace.key.id stays
bare: ingestion stamps it on the resource and nothing sends it as an
attribute. deployment.environment is dropped; with semconv families on
either spelling reads the other, so one key keeps the gate sound.
2026-10-08 00:35:54 +05:30
vikrantgupta25
3f51075634 feat(authz): support service and environment keys in telemetry selectors
Keys stay independent: no fold between deployment.environment and
deployment.environment.name, and the per-atom check is unchanged.
2026-10-02 19:34:00 +05:30
9 changed files with 443 additions and 79 deletions

View File

@@ -3,6 +3,7 @@ package querybuilder
import (
"context"
"encoding/json"
"slices"
"strings"
"github.com/SigNoz/signoz/pkg/errors"
@@ -14,7 +15,10 @@ import (
)
func TelemetrySelector(_ context.Context, resource coretypes.Resource, id string, _ valuer.UUID) ([]coretypes.Selector, error) {
values := telemetrytypes.NewTelemetryGrantSelectors(id)
values, err := telemetrySelectorValues(id)
if err != nil {
return nil, err
}
selectors := make([]coretypes.Selector, 0, len(values))
for _, value := range values {
@@ -182,27 +186,103 @@ func builderQuerySelectors(queryType, expression string, variables map[string]qb
return nil, err
}
ids := make([]string, 0)
atoms := make([]WhereClauseCondition, 0)
for _, condition := range normalized.Conditions {
if !condition.TopLevel {
if !condition.TopLevel || len(condition.Values) == 0 {
continue
}
key, ok := telemetrytypes.NewTelemetryGrantKey(condition.Key)
if !ok {
if condition.Operator != "=" && condition.Operator != "IN" {
continue
}
if condition.Operator == "=" || condition.Operator == "IN" {
for _, value := range condition.Values {
ids = append(ids, queryType+"/"+key+"/"+value)
}
if _, ok := telemetrytypes.NewTelemetryGrantKey(condition.Key); !ok {
continue
}
atoms = append(atoms, condition)
}
if len(atoms) == 0 {
return []string{typeWildcard}, nil
}
if len(ids) == 0 {
return []string{typeWildcard}, nil
listIndex := -1
for index, atom := range atoms {
if atom.Operator != "IN" || len(atom.Values) < 2 {
continue
}
if listIndex >= 0 {
return []string{queryType + "/" + telemetryAtomsFilter(atoms)}, nil
}
listIndex = index
}
if listIndex < 0 {
return []string{queryType + "/" + telemetryAtomsFilter(atoms)}, nil
}
list := atoms[listIndex]
ids := make([]string, 0, len(list.Values))
for _, value := range list.Values {
atoms[listIndex] = WhereClauseCondition{Key: list.Key, Operator: "=", Values: []string{value}}
ids = append(ids, queryType+"/"+telemetryAtomsFilter(atoms))
}
return ids, nil
}
func telemetryAtomsFilter(atoms []WhereClauseCondition) string {
parts := make([]string, 0, len(atoms))
for _, atom := range atoms {
literals := make([]string, 0, len(atom.Values))
for _, value := range atom.Values {
literals = append(literals, FilterStringLiteral(value))
}
if len(literals) == 1 {
parts = append(parts, atom.Key+" = "+literals[0])
continue
}
parts = append(parts, atom.Key+" IN ("+strings.Join(literals, ", ")+")")
}
slices.Sort(parts)
return strings.Join(slices.Compact(parts), " AND ")
}
func telemetrySelectorValues(id string) ([]string, error) {
queryType, filter, found := strings.Cut(id, "/")
if !found || filter == coretypes.WildCardSelectorString {
return telemetrytypes.NewTelemetryGrantSelectors(id), nil
}
normalized, err := NormalizeWhereClause(filter, nil)
if err != nil {
return nil, err
}
values := make([]string, 0)
for _, condition := range normalized.Conditions {
if !condition.TopLevel {
continue
}
key, ok := telemetrytypes.NewTelemetryGrantKey(condition.Key)
if !ok {
continue
}
switch {
case condition.Operator == "=", condition.Operator == "IN" && len(condition.Values) == 1:
values = append(values, queryType+"/"+key+"/"+condition.Values[0], queryType+"/"+key+"/"+coretypes.WildCardSelectorString)
case condition.Operator == "IN":
values = append(values, queryType+"/"+key+"/"+coretypes.WildCardSelectorString)
}
}
values = append(values, queryType+"/"+coretypes.WildCardSelectorString, coretypes.WildCardSelectorString)
seen := make(map[string]struct{}, len(values))
unique := make([]string, 0, len(values))
for _, value := range values {
if _, ok := seen[value]; ok {
continue
}
seen[value] = struct{}{}
unique = append(unique, value)
}
return unique, nil
}

View File

@@ -21,100 +21,172 @@ func TestQueryRangeResources(t *testing.T) {
expected []coretypes.ResourceWithID
}{
{
name: "top level key equality",
name: "TopLevelEquality_OneResource",
body: builderQueryBody("logs", "signoz.workspace.key.id = 'checkout' AND status = 500"),
expected: []coretypes.ResourceWithID{
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/signoz.workspace.key.id/checkout"},
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/signoz.workspace.key.id = 'checkout'"},
},
},
{
name: "resource prefixed key",
name: "ResourcePrefixedKey_KeptInID",
body: builderQueryBody("traces", "resource.signoz.workspace.key.id = 'checkout'"),
expected: []coretypes.ResourceWithID{
{Resource: coretypes.ResourceTelemetryResourceTraces, ID: "builder_query/signoz.workspace.key.id/checkout"},
{Resource: coretypes.ResourceTelemetryResourceTraces, ID: "builder_query/resource.signoz.workspace.key.id = 'checkout'"},
},
},
{
name: "in atom requires every value",
name: "SingleList_OneResourcePerValue",
body: builderQueryBody("logs", "signoz.workspace.key.id IN ('b', 'a')"),
expected: []coretypes.ResourceWithID{
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/signoz.workspace.key.id/a"},
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/signoz.workspace.key.id/b"},
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/signoz.workspace.key.id = 'a'"},
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/signoz.workspace.key.id = 'b'"},
},
},
{
name: "multiple equality atoms each require a grant",
name: "SameKeyEqualities_OneResource",
body: builderQueryBody("logs", "signoz.workspace.key.id = 'b' AND signoz.workspace.key.id = 'a'"),
expected: []coretypes.ResourceWithID{
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/signoz.workspace.key.id/a"},
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/signoz.workspace.key.id/b"},
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/signoz.workspace.key.id = 'a' AND signoz.workspace.key.id = 'b'"},
},
},
{
name: "no filter expression",
name: "DifferentKeyEqualities_OneResource",
body: builderQueryBody("logs", "resource.service.name = 'checkout' AND resource.deployment.environment.name = 'prod'"),
expected: []coretypes.ResourceWithID{
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/resource.deployment.environment.name = 'prod' AND resource.service.name = 'checkout'"},
},
},
{
name: "SingleListWithEquality_ExpandsPerValue",
body: builderQueryBody("logs", "resource.service.name IN ('b', 'a') AND signoz.workspace.key.id = 'k'"),
expected: []coretypes.ResourceWithID{
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/resource.service.name = 'a' AND signoz.workspace.key.id = 'k'"},
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/resource.service.name = 'b' AND signoz.workspace.key.id = 'k'"},
},
},
{
name: "TwoLists_OneResource",
body: builderQueryBody("logs", "resource.service.name IN ('a', 'b') AND signoz.workspace.key.id IN ('y', 'z')"),
expected: []coretypes.ResourceWithID{
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/resource.service.name IN ('a', 'b') AND signoz.workspace.key.id IN ('y', 'z')"},
},
},
{
name: "SingleValueListBesideList_Pinned",
body: builderQueryBody("logs", "resource.service.name IN ('a', 'b') AND signoz.workspace.key.id IN ('k')"),
expected: []coretypes.ResourceWithID{
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/resource.service.name = 'a' AND signoz.workspace.key.id = 'k'"},
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/resource.service.name = 'b' AND signoz.workspace.key.id = 'k'"},
},
},
{
name: "SingleValueListsOnly_Pinned",
body: builderQueryBody("logs", "resource.service.name IN ('a') AND signoz.workspace.key.id IN ('k')"),
expected: []coretypes.ResourceWithID{
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/resource.service.name = 'a' AND signoz.workspace.key.id = 'k'"},
},
},
{
name: "RepeatedList_ExpandsOnce",
body: builderQueryBody("logs", "resource.service.name IN ('checkout') AND resource.service.name IN ('checkout')"),
expected: []coretypes.ResourceWithID{
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/resource.service.name = 'checkout'"},
},
},
{
name: "TwoListsOneKey_OneResource",
body: builderQueryBody("logs", "signoz.workspace.key.id IN ('a', 'b') AND resource.signoz.workspace.key.id IN ('b', 'c')"),
expected: []coretypes.ResourceWithID{
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/resource.signoz.workspace.key.id IN ('b', 'c') AND signoz.workspace.key.id IN ('a', 'b')"},
},
},
{
name: "OldEnvironmentSpelling_NotAnAtom",
body: builderQueryBody("traces", "resource.deployment.environment = 'prod'"),
expected: []coretypes.ResourceWithID{
{Resource: coretypes.ResourceTelemetryResourceTraces, ID: "builder_query/*"},
},
},
{
name: "ServiceList_OneResourcePerValue",
body: builderQueryBody("logs", "resource.service.name IN ('frontend', 'checkout')"),
expected: []coretypes.ResourceWithID{
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/resource.service.name = 'checkout'"},
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/resource.service.name = 'frontend'"},
},
},
{
name: "BareCustomerKey_NotAnAtom",
body: builderQueryBody("logs", "service.name = 'checkout'"),
expected: []coretypes.ResourceWithID{
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/*"},
},
},
{
name: "NoFilter_QueryTypeWildcard",
body: `{"compositeQuery":{"queries":[{"type":"builder_query","spec":{"signal":"logs"}}]}}`,
expected: []coretypes.ResourceWithID{
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/*"},
},
},
{
name: "key atom under or does not qualify",
name: "TopLevelOr_NoAtoms",
body: builderQueryBody("logs", "signoz.workspace.key.id = 'a' OR status = 500"),
expected: []coretypes.ResourceWithID{
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/*"},
},
},
{
name: "negated key atom does not qualify",
body: builderQueryBody("logs", "NOT signoz.workspace.key.id = 'a'"),
name: "ListUnderOr_NoAtoms",
body: builderQueryBody("logs", "signoz.workspace.key.id IN ('a', 'b') OR status = 500"),
expected: []coretypes.ResourceWithID{
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/*"},
},
},
{
name: "key inequality does not qualify",
body: builderQueryBody("logs", "signoz.workspace.key.id != 'a'"),
name: "NestedNegationUnion_NoAtoms",
body: builderQueryBody("logs", "NOT (NOT resource.service.name = 'checkout' AND NOT resource.service.name = 'frontend')"),
expected: []coretypes.ResourceWithID{
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/*"},
},
},
{
name: "value with spaces and slashes stays plaintext in the id",
name: "ValueWithSpacesAndSlashes_Plaintext",
body: builderQueryBody("logs", "signoz.workspace.key.id = 'check out/2'"),
expected: []coretypes.ResourceWithID{
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/signoz.workspace.key.id/check out/2"},
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/signoz.workspace.key.id = 'check out/2'"},
},
},
{
name: "audit source maps to audit logs resource",
name: "AuditSource_AuditLogsResource",
body: `{"compositeQuery":{"queries":[{"type":"builder_query","spec":{"signal":"logs","source":"audit","filter":{"expression":"signoz.workspace.key.id = 'a'"}}}]}}`,
expected: []coretypes.ResourceWithID{
{Resource: coretypes.ResourceTelemetryResourceAuditLogs, ID: "builder_query/signoz.workspace.key.id/a"},
{Resource: coretypes.ResourceTelemetryResourceAuditLogs, ID: "builder_query/signoz.workspace.key.id = 'a'"},
},
},
{
name: "ai builder query maps to traces resource without a signal",
name: "AIBuilderQuery_TracesResource",
body: `{"compositeQuery":{"queries":[{"type":"builder_ai_query","spec":{"filter":{"expression":"signoz.workspace.key.id = 'checkout'"}}}]}}`,
expected: []coretypes.ResourceWithID{
{Resource: coretypes.ResourceTelemetryResourceTraces, ID: "builder_ai_query/signoz.workspace.key.id/checkout"},
{Resource: coretypes.ResourceTelemetryResourceTraces, ID: "builder_ai_query/signoz.workspace.key.id = 'checkout'"},
},
},
{
name: "ai builder query without filter is wildcard",
name: "AIBuilderQueryNoFilter_Wildcard",
body: `{"compositeQuery":{"queries":[{"type":"builder_ai_query","spec":{}}]}}`,
expected: []coretypes.ResourceWithID{
{Resource: coretypes.ResourceTelemetryResourceTraces, ID: "builder_ai_query/*"},
},
},
{
name: "promql is wildcard only",
name: "PromQL_WildcardOnly",
body: `{"compositeQuery":{"queries":[{"type":"promql","spec":{"query":"up"}}]}}`,
expected: []coretypes.ResourceWithID{
{Resource: coretypes.ResourceTelemetryResourceMetrics, ID: "promql/*"},
},
},
{
name: "clickhouse sql covers all signals",
name: "ClickHouseSQL_AllSignals",
body: `{"compositeQuery":{"queries":[{"type":"clickhouse_sql","spec":{"query":"SELECT 1"}}]}}`,
expected: []coretypes.ResourceWithID{
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "clickhouse_sql/*"},
@@ -124,29 +196,29 @@ func TestQueryRangeResources(t *testing.T) {
},
},
{
name: "formula produces no resources",
name: "Formula_NoResources",
body: `{"compositeQuery":{"queries":[{"type":"builder_formula","spec":{"expression":"A/B"}}]}}`,
expected: []coretypes.ResourceWithID{},
},
{
name: "trace operator rides on its referenced queries",
name: "TraceOperator_ReferencedQueries",
body: `{"compositeQuery":{"queries":[{"type":"builder_query","spec":{"name":"A","signal":"traces","disabled":true,"filter":{"expression":"signoz.workspace.key.id = 'checkout'"}}},{"type":"builder_query","spec":{"name":"B","signal":"traces","disabled":true,"filter":{"expression":"signoz.workspace.key.id = '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/signoz.workspace.key.id/checkout"},
{Resource: coretypes.ResourceTelemetryResourceTraces, ID: "builder_query/signoz.workspace.key.id = 'checkout'"},
},
},
{
name: "variable substitution qualifies",
name: "VariableSubstitution_Qualifies",
body: `{"variables":{"key":{"value":"checkout"}},"compositeQuery":{"queries":[{"type":"builder_query","spec":{"signal":"logs","filter":{"expression":"signoz.workspace.key.id = $key"}}}]}}`,
expected: []coretypes.ResourceWithID{
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/signoz.workspace.key.id/checkout"},
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/signoz.workspace.key.id = 'checkout'"},
},
},
{
name: "duplicate queries dedupe",
name: "DuplicateQueries_Deduped",
body: `{"compositeQuery":{"queries":[{"type":"builder_query","spec":{"signal":"logs","filter":{"expression":"signoz.workspace.key.id = 'a'"}}},{"type":"builder_query","spec":{"signal":"logs","filter":{"expression":"signoz.workspace.key.id='a'"}}}]}}`,
expected: []coretypes.ResourceWithID{
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/signoz.workspace.key.id/a"},
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/signoz.workspace.key.id = 'a'"},
},
},
}
@@ -188,12 +260,28 @@ func TestTelemetrySelector(t *testing.T) {
return values
}
assert.Equal(t, []string{"builder_query/signoz.workspace.key.id/a", "builder_query/signoz.workspace.key.id/*", "builder_query/*", "*"}, selectorValues("builder_query/signoz.workspace.key.id/a"))
assert.Equal(t, []string{"builder_query/signoz.workspace.key.id/a", "builder_query/signoz.workspace.key.id/*", "builder_query/*", "*"}, selectorValues("builder_query/signoz.workspace.key.id = 'a'"))
assert.Equal(t, []string{"builder_query/signoz.workspace.key.id/a", "builder_query/signoz.workspace.key.id/*", "builder_query/*", "*"}, selectorValues("builder_query/resource.signoz.workspace.key.id = 'a' AND status = 500"))
assert.Equal(t, []string{
"builder_query/resource.service.name/x",
"builder_query/resource.service.name/*",
"builder_query/signoz.workspace.key.id/z",
"builder_query/signoz.workspace.key.id/*",
"builder_query/*",
"*",
}, selectorValues("builder_query/resource.service.name = 'x' AND signoz.workspace.key.id = 'z'"))
assert.Equal(t, []string{"builder_query/signoz.workspace.key.id/*", "builder_query/*", "*"}, selectorValues("builder_query/signoz.workspace.key.id IN ('a', 'b')"))
assert.Equal(t, []string{"builder_query/signoz.workspace.key.id/a", "builder_query/signoz.workspace.key.id/*", "builder_query/*", "*"}, selectorValues("builder_query/signoz.workspace.key.id IN ('a')"))
assert.Equal(t, []string{"builder_query/*", "*"}, selectorValues("builder_query/signoz.workspace.key.id = 'a' OR status = 500"))
assert.Equal(t, []string{"builder_query/*", "*"}, selectorValues("builder_query/service.name = 'x'"))
assert.Equal(t, []string{"builder_query/signoz.workspace.key.id/a/b", "builder_query/signoz.workspace.key.id/*", "builder_query/*", "*"}, selectorValues("builder_query/signoz.workspace.key.id = 'a/b'"))
assert.Equal(t, []string{"builder_query/*", "*"}, selectorValues("builder_query/*"))
assert.Equal(t, []string{"builder_query/*", "*"}, selectorValues("builder_query"))
assert.Equal(t, []string{"promql/*", "*"}, selectorValues("promql"))
assert.Equal(t, []string{"promql/*", "*"}, selectorValues("promql/*"))
assert.Equal(t, []string{"*"}, selectorValues("*"))
// a value containing "/" stays one logical segment (SplitN 3).
assert.Equal(t, []string{"builder_query/signoz.workspace.key.id/a/b", "builder_query/signoz.workspace.key.id/*", "builder_query/*", "*"}, selectorValues("builder_query/signoz.workspace.key.id/a/b"))
_, err := TelemetrySelector(context.Background(), coretypes.ResourceTelemetryResourceLogs, "builder_query/signoz.workspace.key.id = ", orgID)
assert.Error(t, err)
}
func TestBuilderQueryWithFilterExpressionResources(t *testing.T) {
@@ -205,7 +293,7 @@ func TestBuilderQueryWithFilterExpressionResources(t *testing.T) {
{
name: "FilterPresent_ScopedID",
body: `{"start":1,"end":2,"filter":{"expression":"signoz.workspace.key.id = 'key-a'"}}`,
expected: []coretypes.ResourceWithID{{Resource: coretypes.ResourceTelemetryResourceTraces, ID: "builder_query/signoz.workspace.key.id/key-a"}},
expected: []coretypes.ResourceWithID{{Resource: coretypes.ResourceTelemetryResourceTraces, ID: "builder_query/signoz.workspace.key.id = 'key-a'"}},
},
{
name: "FilterAbsent_Wildcard",

View File

@@ -2,6 +2,7 @@ package querybuilder
import (
"fmt"
"slices"
"sort"
"strconv"
"strings"
@@ -39,6 +40,7 @@ const (
type normalizedPart struct {
text string
join joinKind
negated bool
skipped bool
}
@@ -48,11 +50,12 @@ type normalizedValue struct {
}
type whereClauseNormalizer struct {
variables map[string]qbtypes.VariableItem
conditions []WhereClauseCondition
negated bool
orDepth int
errors []string
variables map[string]qbtypes.VariableItem
conditions []WhereClauseCondition
negated bool
orDepth int
negationDepth int
errors []string
}
func NormalizeWhereClause(expression string, variables map[string]qbtypes.VariableItem) (*NormalizedWhereClause, error) {
@@ -113,8 +116,11 @@ func NormalizeWhereClause(expression string, variables map[string]qbtypes.Variab
sort.Slice(visitor.conditions, func(i, j int) bool {
return visitor.conditions[i].sortKey() < visitor.conditions[j].sortKey()
})
conditions := slices.CompactFunc(visitor.conditions, func(left, right WhereClauseCondition) bool {
return left.sortKey() == right.sortKey()
})
return &NormalizedWhereClause{Expression: part.text, Conditions: visitor.conditions}, nil
return &NormalizedWhereClause{Expression: part.text, Conditions: conditions}, nil
}
func (condition WhereClauseCondition) sortKey() string {
@@ -197,20 +203,22 @@ func (visitor *whereClauseNormalizer) visitUnaryExpression(ctx grammar.IUnaryExp
negated := ctx.NOT() != nil
if negated {
visitor.negated = !visitor.negated
visitor.negationDepth++
}
part := visitor.visitPrimary(ctx.Primary())
if negated {
visitor.negated = !visitor.negated
visitor.negationDepth--
if part.skipped {
return part
}
if part.join != joinKindNone {
return normalizedPart{text: "NOT (" + part.text + ")", join: joinKindNone}
if part.join != joinKindNone || part.negated {
return normalizedPart{text: "NOT (" + part.text + ")", negated: true}
}
return normalizedPart{text: "NOT " + part.text, join: joinKindNone}
return normalizedPart{text: "NOT " + part.text, negated: true}
}
return part
@@ -439,7 +447,7 @@ func (visitor *whereClauseNormalizer) appendCondition(key, operator string, valu
Operator: operator,
Values: values,
Negated: visitor.negated,
TopLevel: visitor.orDepth == 0 && !visitor.negated,
TopLevel: visitor.orDepth == 0 && visitor.negationDepth == 0,
})
}

View File

@@ -25,6 +25,15 @@ func TestNormalizeWhereClauseEquivalenceClasses(t *testing.T) {
},
expected: "service.name = 'frontend' AND status = 200",
},
{
name: "nested not keeps its parentheses",
expressions: []string{
"NOT (NOT a = 1)",
"NOT(NOT a=1)",
"not (not a = 1)",
},
expected: "NOT (NOT a = 1)",
},
{
name: "operand order",
expressions: []string{
@@ -275,15 +284,25 @@ func TestNormalizeWhereClauseTopLevel(t *testing.T) {
expected: map[string]bool{"service.name": false, "status": true},
},
{
name: "double negation restores top level",
name: "any negation disqualifies even when it cancels",
expression: "NOT (NOT (service.name = 'a'))",
expected: map[string]bool{"service.name": true},
expected: map[string]bool{"service.name": false},
},
{
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},
},
{
name: "nested negation over an and is a disjunction",
expression: "NOT (NOT service.name = 'a' AND NOT status = 500)",
expected: map[string]bool{"service.name": false, "status": false},
},
{
name: "negation over an or is a conjunction of negations",
expression: "NOT (service.name = 'a' OR status = 500)",
expected: map[string]bool{"service.name": false, "status": false},
},
}
for _, testCase := range testCases {
@@ -327,6 +346,8 @@ func TestNormalizeWhereClauseIdempotence(t *testing.T) {
"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"'`,
"NOT (NOT resource.region = 'us-east')",
"NOT (NOT (a = 1 OR b = 2)) AND NOT c EXISTS",
}
for _, expression := range expressions {
@@ -439,3 +460,13 @@ func TestFilterStringLiteral(t *testing.T) {
})
}
}
func TestNormalizeWhereClauseDedupesRepeatedConditions(t *testing.T) {
normalized, err := NormalizeWhereClause("service.name IN ('checkout') AND service.name IN ('checkout') AND status = 500 AND status = 500", nil)
require.NoError(t, err)
assert.Equal(t, "service.name IN ('checkout') AND status = 500", normalized.Expression)
assert.Equal(t, []WhereClauseCondition{
{Key: "service.name", Operator: "IN", Values: []string{"checkout"}, TopLevel: true},
{Key: "status", Operator: "=", Values: []string{"500"}, TopLevel: true},
}, normalized.Conditions)
}

View File

@@ -1,6 +1,7 @@
package telemetrytypes
import (
"slices"
"strings"
"github.com/SigNoz/signoz/pkg/errors"
@@ -16,8 +17,12 @@ var telemetryGrantQueryTypes = map[string]bool{
"clickhouse_sql": false,
}
var telemetryGrantKeys = map[string]struct{}{
"signoz.workspace.key.id": {},
// signoz.workspace.key.id is stamped on the resource by ingestion and never arrives as an
// attribute, so its bare spelling is unambiguous; customer-controlled keys must say resource.
var telemetryGrantKeys = map[string]string{
"signoz.workspace.key.id": "signoz.workspace.key.id",
"service.name": "resource.service.name",
"deployment.environment.name": "resource.deployment.environment.name",
}
func NewTelemetryGrantKey(keyText string) (string, bool) {
@@ -26,11 +31,16 @@ func NewTelemetryGrantKey(keyText string) (string, bool) {
return "", false
}
if _, ok := telemetryGrantKeys[fieldKey.Name]; !ok {
canonical, ok := telemetryGrantKeys[fieldKey.Name]
if !ok {
return "", false
}
return fieldKey.Name, true
if fieldKey.FieldContext == FieldContextUnspecified && canonical != fieldKey.Name {
return "", false
}
return canonical, true
}
func NewTelemetryGrantSelector(input string) (string, error) {
@@ -104,8 +114,9 @@ func NewTelemetryGrantSelectors(selector string) []string {
func telemetryGrantKeyNames() []string {
names := make([]string, 0, len(telemetryGrantKeys))
for name := range telemetryGrantKeys {
names = append(names, name)
for _, canonical := range telemetryGrantKeys {
names = append(names, canonical)
}
slices.Sort(names)
return names
}

View File

@@ -14,11 +14,14 @@ func TestNewTelemetryGrantSelector(t *testing.T) {
"builder_query/*": "builder_query/*",
"promql": "promql/*",
"clickhouse_sql": "clickhouse_sql/*",
"builder_query/signoz.workspace.key.id/*": "builder_query/signoz.workspace.key.id/*",
"builder_query/signoz.workspace.key.id/key-a": "builder_query/signoz.workspace.key.id/key-a",
"builder_query/resource.signoz.workspace.key.id/key-a": "builder_query/signoz.workspace.key.id/key-a",
"builder_query/signoz.workspace.key.id/key a": "builder_query/signoz.workspace.key.id/key a",
"builder_query/signoz.workspace.key.id/a/b": "builder_query/signoz.workspace.key.id/a/b",
"builder_query/signoz.workspace.key.id/*": "builder_query/signoz.workspace.key.id/*",
"builder_query/signoz.workspace.key.id/key-a": "builder_query/signoz.workspace.key.id/key-a",
"builder_query/resource.signoz.workspace.key.id/key-a": "builder_query/signoz.workspace.key.id/key-a",
"builder_query/signoz.workspace.key.id/key a": "builder_query/signoz.workspace.key.id/key a",
"builder_query/signoz.workspace.key.id/a/b": "builder_query/signoz.workspace.key.id/a/b",
"builder_query/resource.service.name/frontend": "builder_query/resource.service.name/frontend",
"builder_query/resource.service.name/*": "builder_query/resource.service.name/*",
"builder_query/resource.deployment.environment.name/prod": "builder_query/resource.deployment.environment.name/prod",
}
for input, expected := range valid {
canonical, err := NewTelemetryGrantSelector(input)
@@ -32,6 +35,10 @@ func TestNewTelemetryGrantSelector(t *testing.T) {
"signoz.workspace.key.id = 'key-a'",
"builder_trace_operator/signoz.workspace.key.id/key-a",
"builder_query/service.name/frontend",
"builder_query/deployment.environment.name/prod",
"builder_query/resource.deployment.environment/prod",
"builder_query/attribute.service.name/frontend",
"builder_query/host.name/frontend",
"builder_query/signoz.workspace.key.id/",
"builder_query/signoz.workspace.key.id/$svc",
"*/signoz.workspace.key.id/key-a",
@@ -48,8 +55,10 @@ func TestNewTelemetryGrantSelector(t *testing.T) {
func TestNewTelemetryGrantKey(t *testing.T) {
valid := map[string]string{
"signoz.workspace.key.id": "signoz.workspace.key.id",
"resource.signoz.workspace.key.id": "signoz.workspace.key.id",
"signoz.workspace.key.id": "signoz.workspace.key.id",
"resource.signoz.workspace.key.id": "signoz.workspace.key.id",
"resource.service.name": "resource.service.name",
"resource.deployment.environment.name": "resource.deployment.environment.name",
}
for keyText, expected := range valid {
key, ok := NewTelemetryGrantKey(keyText)
@@ -57,7 +66,7 @@ func TestNewTelemetryGrantKey(t *testing.T) {
assert.Equal(t, expected, key, keyText)
}
for _, keyText := range []string{"service.name", "attribute.signoz.workspace.key.id", "body.signoz.workspace.key.id"} {
for _, keyText := range []string{"service.name", "deployment.environment.name", "resource.deployment.environment", "host.name", "attribute.signoz.workspace.key.id", "attribute.service.name", "body.signoz.workspace.key.id"} {
_, ok := NewTelemetryGrantKey(keyText)
assert.False(t, ok, keyText)
}

View File

@@ -250,7 +250,7 @@ def test_filtered_routes_allow_granted_key(
timeout=10,
)
assert denied.status_code == HTTPStatus.FORBIDDEN, denied.text
assert "builder_query/signoz.workspace.key.id/key-b" in denied.text
assert "builder_query/signoz.workspace.key.id = 'key-b'" in denied.text
@pytest.mark.parametrize(

View File

@@ -50,7 +50,9 @@ def test_setup(
[
"signoz.workspace.key.id = 'key-a'", # expression form, not the wire form
"unknown_query_type/signoz.workspace.key.id/key-a", # unsupported query type
"builder_query/service.name/frontend", # service.name is not a supported grant key
"builder_query/service.name/frontend", # customer keys must carry the resource prefix
"builder_query/attribute.service.name/frontend", # grant keys are resource attributes only
"builder_query/host.name/frontend", # host.name is not a supported grant key
"*/signoz.workspace.key.id/key-a", # non-prefix wildcard
"builder_query/signoz.workspace.key.id/", # empty value
"builder_query/signoz.workspace.key.id", # missing value, not a wildcard
@@ -153,7 +155,7 @@ def test_denied_message_names_resource(
request_type="raw",
)
assert response.status_code == HTTPStatus.FORBIDDEN, response.text
assert "builder_query/signoz.workspace.key.id/key-b" in response.text
assert "builder_query/signoz.workspace.key.id = 'key-b'" in response.text
def test_variables_resolve_into_gate(

View File

@@ -0,0 +1,135 @@
from collections.abc import Callable
from datetime import UTC, datetime, timedelta
from http import HTTPStatus
import pytest
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$"
service_role = "telemetry-scope-service"
service_email = "scope-service@telemetry.test"
env_role = "telemetry-scope-env"
env_email = "scope-env@telemetry.test"
two_services_role = "telemetry-scope-two-services"
two_services_email = "scope-two-services@telemetry.test"
two_keys_role = "telemetry-scope-two-keys"
two_keys_email = "scope-two-keys@telemetry.test"
mixed_role = "telemetry-scope-mixed"
mixed_email = "scope-mixed@telemetry.test"
key_wild_role = "telemetry-scope-key-wild"
key_wild_email = "scope-key-wild@telemetry.test"
two_lists = "resource.service.name IN ('checkout', 'frontend') AND signoz.workspace.key.id IN ('k1', 'k2')"
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)
for role, email, selectors in [
(service_role, service_email, ["builder_query/resource.service.name/checkout"]),
(env_role, env_email, ["builder_query/resource.deployment.environment.name/prod"]),
(two_services_role, two_services_email, ["builder_query/resource.service.name/checkout", "builder_query/resource.service.name/frontend"]),
(two_keys_role, two_keys_email, ["builder_query/signoz.workspace.key.id/k1", "builder_query/signoz.workspace.key.id/k2"]),
(mixed_role, mixed_email, ["builder_query/resource.service.name/checkout", "builder_query/signoz.workspace.key.id/k2"]),
(key_wild_role, key_wild_email, ["builder_query/signoz.workspace.key.id/*"]),
]:
create_role(admin_token, role, [transaction_group("read", "telemetryresource", "logs", selectors)])
user_id = create_active_user(signoz, admin_token, email=email, role="signoz-viewer", password=user_password)
change_user_role(signoz, admin_token, user_id, "signoz-viewer", role)
@pytest.fixture(name="seed_logs")
def seed_logs_fixture(insert_logs: Callable[[list[Logs]], None]) -> datetime:
now = datetime.now(tz=UTC)
insert_logs(
[
Logs(timestamp=now - timedelta(seconds=1), resources={"service.name": "checkout", "signoz.workspace.key.id": "k1", "deployment.environment.name": "prod"}, body="checkout-k1"),
Logs(timestamp=now - timedelta(seconds=2), resources={"service.name": "frontend", "signoz.workspace.key.id": "k2", "deployment.environment.name": "prod"}, body="frontend-k2"),
]
)
return now
@pytest.mark.parametrize(
("email", "expression"),
[
(service_email, "resource.service.name = 'checkout'"),
(service_email, "resource.service.name IN ('checkout')"),
(service_email, "resource.service.name = 'checkout' AND severity_text = 'ERROR'"),
# any granted atom selects the query; the other atoms only narrow it
(service_email, "resource.service.name = 'checkout' AND resource.deployment.environment.name = 'prod'"),
(env_email, "resource.service.name = 'checkout' AND resource.deployment.environment.name = 'prod'"),
(service_email, "resource.service.name IN ('checkout') AND signoz.workspace.key.id IN ('k1', 'k2')"),
# a single list is a union: every value, or another granted atom, covers it
(two_services_email, "resource.service.name IN ('checkout', 'frontend') AND signoz.workspace.key.id IN ('k1')"),
(two_keys_email, "resource.service.name IN ('checkout') AND signoz.workspace.key.id IN ('k1', 'k2')"),
# two lists are not expanded: only a key wildcard covers them
(key_wild_email, two_lists),
],
)
def test_allowed(
signoz: types.SigNoz,
get_token: Callable[[str, str], str],
seed_logs: datetime,
email: str,
expression: str,
) -> None:
response = make_query_request(
signoz,
get_token(email, user_password),
int((seed_logs - timedelta(minutes=10)).timestamp() * 1000),
int(seed_logs.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(
("email", "expression", "denied_resource"),
[
(service_email, "service.name = 'checkout'", "builder_query/*"), # bare customer key never selects
# NOT(NOT a AND NOT b) == a OR b: nested negations must not mark either value as a required atom
(service_email, "NOT (NOT resource.service.name = 'checkout' AND NOT resource.service.name = 'frontend')", "builder_query/*"),
(service_email, "resource.deployment.environment.name = 'prod'", "builder_query/resource.deployment.environment.name = 'prod'"),
(service_email, "resource.service.name = 'frontend'", "builder_query/resource.service.name = 'frontend'"),
(env_email, "resource.service.name = 'checkout'", "builder_query/resource.service.name = 'checkout'"),
(
service_email,
"resource.service.name IN ('checkout', 'frontend') AND resource.deployment.environment.name = 'prod'",
"builder_query/resource.deployment.environment.name = 'prod' AND resource.service.name = 'frontend'",
),
# two lists are not expanded, so value grants never cover them
(two_services_email, two_lists, "builder_query/resource.service.name IN ('checkout', 'frontend') AND signoz.workspace.key.id IN ('k1', 'k2')"),
(two_keys_email, two_lists, "builder_query/resource.service.name IN ('checkout', 'frontend') AND signoz.workspace.key.id IN ('k1', 'k2')"),
(mixed_email, two_lists, "builder_query/resource.service.name IN ('checkout', 'frontend') AND signoz.workspace.key.id IN ('k1', 'k2')"),
],
)
def test_denied(
signoz: types.SigNoz,
get_token: Callable[[str, str], str],
seed_logs: datetime,
email: str,
expression: str,
denied_resource: str,
) -> None:
response = make_query_request(
signoz,
get_token(email, user_password),
int((seed_logs - timedelta(minutes=10)).timestamp() * 1000),
int(seed_logs.timestamp() * 1000),
[build_raw_query("A", "logs", limit=50, filter_expression=expression)],
request_type="raw",
)
assert response.status_code == HTTPStatus.FORBIDDEN, response.text
assert denied_resource in response.text