Closes the threat model's headline finding for the SQL query path:
enterprise/internal/tenantprovision does real CREATE DATABASE/USER/GRANT
against ClickHouse, and enterprise/internal/chrunner is a per-tenant
connection registry implementing api's SQLRunner interface, resolving
the tenant from the authenticated request identity -- never a
caller-suppliable parameter. Both are wired into a new binary,
enterprise/cmd/enterprise-api, alongside the unchanged single-tenant
api/cmd/api, since AGPL core can never import enterprise/ and Go's own
internal/ package visibility rules meant enterprise/ couldn't implement
core's SQLRunner interface without importing the package that defines
it. That required moving api/internal/{authz,queryapi,dashboards,
querylang/executor,searchclient,httpserver} out of internal/ -- the
minimal set enterprise-api needs to import; querylang's compiler
internals (planner/lexer/parser/ast/ir) and api's own config stay
internal, since nothing outside api needs them directly.
Also finally wires enterprise/internal/audit into queryapi.AuditLogger
(nil since Phase 4 task 4) via a new adapter, and adds live-ClickHouse
integration tests for two of the four adversarial probes named in
docs/phase-4-isolation-design.md's verification plan.
Corrected several overclaims in the docs while writing this up: an
earlier claim that rbacstore's CRUD was "verified against a live
Postgres" was never actually true in this environment (only
internal/audit was, earlier in this phase, before Docker access was
lost) -- threat-model.md, phase-4-runbook.md, CLAUDE.md, and
enterprise/README.md all now distinguish "a real integration test
exists" from "this was confirmed against a live database."
Still not built: Tantivy/free-text tenant isolation
(enterprise/internal/searchclient), and any deployment-topology
mechanism that actually routes traffic to enterprise-api instead of
plain api -- both binaries exist side by side today with nothing
enforcing or flagging which one a deployment runs.
287 lines
8.2 KiB
Go
287 lines
8.2 KiB
Go
// Package planner compiles a query string (either syntax) into ir.Plan.
|
|
// This is the single entry point querylang exposes to callers (the /query
|
|
// HTTP handler) -- see Compile.
|
|
package planner
|
|
|
|
import (
|
|
"fmt"
|
|
"regexp"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/sentry/sentry/api/internal/querylang/ast"
|
|
"github.com/sentry/sentry/api/internal/querylang/ir"
|
|
"github.com/sentry/sentry/api/internal/querylang/parser"
|
|
)
|
|
|
|
// Language selects which syntax a query is written in.
|
|
type Language string
|
|
|
|
const (
|
|
Auto Language = "" // detect from the query text (default)
|
|
SQL Language = "sql"
|
|
SPL Language = "spl" // the pipe syntax; named to match the query-language-reference doc
|
|
)
|
|
|
|
const defaultLimit = 100
|
|
|
|
// Compile turns a query string into a Plan. language overrides
|
|
// auto-detection; pass Auto to use the SELECT-prefix heuristic (see
|
|
// /docs/query-language-design.md's "Detection" section) -- this exists
|
|
// for the rare case a pipe query legitimately starts with the literal
|
|
// word "select" as a bare search term.
|
|
func Compile(query string, language Language, now time.Time) (*ir.Plan, error) {
|
|
isSQL := language == SQL
|
|
if language == Auto {
|
|
isSQL = looksLikeSQL(query)
|
|
}
|
|
|
|
if isSQL {
|
|
if err := validateSelectOnly(query); err != nil {
|
|
return nil, err
|
|
}
|
|
return &ir.Plan{RawSQL: strings.TrimSpace(strings.TrimSuffix(strings.TrimSpace(query), ";"))}, nil
|
|
}
|
|
|
|
q, err := parser.Parse(query)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return compileQuery(q, now)
|
|
}
|
|
|
|
func looksLikeSQL(query string) bool {
|
|
trimmed := strings.TrimSpace(query)
|
|
if trimmed == "" {
|
|
return false
|
|
}
|
|
fields := strings.Fields(trimmed)
|
|
return len(fields) > 0 && strings.EqualFold(fields[0], "SELECT")
|
|
}
|
|
|
|
func compileQuery(q *ast.Query, now time.Time) (*ir.Plan, error) {
|
|
plan := &ir.Plan{}
|
|
|
|
textParts, err := compileBoolExpr(q.Base, plan, now)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
for _, stage := range q.Pipes {
|
|
switch s := stage.(type) {
|
|
case ast.WhereStage:
|
|
parts, err := compileBoolExpr(s.Expr, plan, now)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
textParts = append(textParts, parts...)
|
|
case ast.StatsStage:
|
|
agg, err := compileStats(s)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
plan.Aggregation = agg
|
|
case ast.SortStage:
|
|
for _, f := range s.Fields {
|
|
plan.Sort = append(plan.Sort, ir.SortField{Field: f.Field, Desc: f.Desc})
|
|
}
|
|
case ast.FieldsStage:
|
|
plan.Fields = append(plan.Fields, s.Fields...)
|
|
case ast.HeadStage:
|
|
n := defaultLimit
|
|
if s.HasN {
|
|
n = s.N
|
|
}
|
|
plan.Limit = &ir.Limit{N: n, Tail: false}
|
|
case ast.TailStage:
|
|
n := defaultLimit
|
|
if s.HasN {
|
|
n = s.N
|
|
}
|
|
plan.Limit = &ir.Limit{N: n, Tail: true}
|
|
default:
|
|
return nil, fmt.Errorf("internal error: unhandled pipe stage %T", stage)
|
|
}
|
|
}
|
|
|
|
if len(textParts) > 0 {
|
|
plan.TextSearch = []ir.TextPredicate{{Query: strings.Join(textParts, " ")}}
|
|
}
|
|
|
|
return plan, nil
|
|
}
|
|
|
|
// textPart is one piece of a combined Tantivy query string, tagged with
|
|
// the conjunction that precedes it (empty for the first piece).
|
|
type textPart struct {
|
|
conj string // "", "and", "or"
|
|
query string
|
|
}
|
|
|
|
// compileBoolExpr walks one bool_expr (the base search, or a `where`
|
|
// stage's expression), populating plan.Filters and plan.TimeRange
|
|
// directly, and returning free-text pieces for the caller to fold into
|
|
// the combined Tantivy query string.
|
|
//
|
|
// Scope decision: "or" is only supported between free-text terms, which
|
|
// Tantivy's own query parser handles natively once composed into one
|
|
// string. "or" between structured comparisons/time-bounds is rejected
|
|
// with a clear error rather than silently compiled as "and" -- see
|
|
// /docs/query-language-reference.md's limitations section. This keeps
|
|
// the executor's generated SQL a flat AND-only WHERE clause, which is
|
|
// most of what real queries need; full boolean-tree support for
|
|
// structured filters is future work if usage shows it's needed.
|
|
func compileBoolExpr(expr ast.BoolExpr, plan *ir.Plan, now time.Time) ([]string, error) {
|
|
var textParts []string
|
|
|
|
for i, term := range expr.Terms {
|
|
conj := ""
|
|
if i > 0 {
|
|
conj = expr.Conjs[i-1]
|
|
}
|
|
|
|
switch t := term.(type) {
|
|
case ast.Comparison:
|
|
if conj == "or" {
|
|
return nil, fmt.Errorf("query error: \"or\" is not supported between structured filters (%q) in Phase 2 -- only between free-text search terms", t.Field)
|
|
}
|
|
plan.Filters = append(plan.Filters, ir.FilterPredicate{Field: t.Field, Op: t.Op, Value: t.Value})
|
|
case ast.TimeBound:
|
|
if conj == "or" {
|
|
return nil, fmt.Errorf("query error: \"or\" is not supported on time bounds (%s) in Phase 2", t.Kind)
|
|
}
|
|
if err := applyTimeBound(plan, t, now); err != nil {
|
|
return nil, err
|
|
}
|
|
case ast.FreeText:
|
|
q := t.Query
|
|
if strings.ContainsAny(q, " \t") {
|
|
q = `"` + strings.ReplaceAll(q, `"`, `\"`) + `"`
|
|
}
|
|
if conj == "or" {
|
|
textParts = append(textParts, "OR", q)
|
|
} else if len(textParts) > 0 {
|
|
textParts = append(textParts, "AND", q)
|
|
} else {
|
|
textParts = append(textParts, q)
|
|
}
|
|
default:
|
|
return nil, fmt.Errorf("internal error: unhandled term %T", term)
|
|
}
|
|
}
|
|
|
|
return textParts, nil
|
|
}
|
|
|
|
func applyTimeBound(plan *ir.Plan, t ast.TimeBound, now time.Time) error {
|
|
when, err := resolveTimeExpr(t.Expr, now)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if plan.TimeRange == nil {
|
|
plan.TimeRange = &ir.TimeRange{}
|
|
}
|
|
switch t.Kind {
|
|
case "earliest":
|
|
plan.TimeRange.From = when
|
|
case "latest":
|
|
plan.TimeRange.To = when
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func resolveTimeExpr(e ast.TimeExpr, now time.Time) (time.Time, error) {
|
|
if !e.IsRelative {
|
|
t, err := time.Parse(time.RFC3339, e.Absolute)
|
|
if err != nil {
|
|
return time.Time{}, fmt.Errorf("query error: invalid absolute timestamp %q, want RFC3339 (e.g. 2026-08-14T00:00:00Z): %w", e.Absolute, err)
|
|
}
|
|
return t, nil
|
|
}
|
|
|
|
var d time.Duration
|
|
switch e.RelativeUnit {
|
|
case "s":
|
|
d = time.Duration(e.RelativeN) * time.Second
|
|
case "m":
|
|
d = time.Duration(e.RelativeN) * time.Minute
|
|
case "h":
|
|
d = time.Duration(e.RelativeN) * time.Hour
|
|
case "d":
|
|
d = time.Duration(e.RelativeN) * 24 * time.Hour
|
|
case "w":
|
|
d = time.Duration(e.RelativeN) * 7 * 24 * time.Hour
|
|
default:
|
|
return time.Time{}, fmt.Errorf("internal error: unknown time unit %q", e.RelativeUnit)
|
|
}
|
|
if e.RelativeSign < 0 {
|
|
d = -d
|
|
}
|
|
return now.Add(d), nil
|
|
}
|
|
|
|
func compileStats(s ast.StatsStage) (*ir.Aggregation, error) {
|
|
agg := &ir.Aggregation{GroupBy: s.By}
|
|
seen := map[string]bool{}
|
|
for _, a := range s.Aggs {
|
|
if a.Func != "count" && a.Field == "" {
|
|
return nil, fmt.Errorf("query error: %s() requires a field, e.g. %s(latency_ms)", a.Func, a.Func)
|
|
}
|
|
alias := a.Alias
|
|
if alias == "" {
|
|
alias = defaultAggAlias(a)
|
|
}
|
|
if seen[alias] && a.Alias == "" {
|
|
// Two unnamed aggs of the same shape would otherwise collide
|
|
// (e.g. `stats sum(a), sum(b)` both defaulting to "sum") --
|
|
// disambiguate by field name.
|
|
alias = alias + "_" + a.Field
|
|
}
|
|
seen[alias] = true
|
|
agg.Funcs = append(agg.Funcs, ir.AggFunc{Func: a.Func, Field: a.Field, Alias: alias})
|
|
}
|
|
return agg, nil
|
|
}
|
|
|
|
func defaultAggAlias(a ast.AggCall) string {
|
|
if a.Func == "count" {
|
|
return "count"
|
|
}
|
|
return a.Func
|
|
}
|
|
|
|
// --- SQL escape hatch validation, ported from the Phase 0/1
|
|
// api/internal/queryapi/validate.go guard it replaces (see task 4) ---
|
|
|
|
// disallowedKeyword is defense-in-depth on top of the SELECT-only gate:
|
|
// it catches mutating/administrative statements appearing anywhere in
|
|
// the query, not just at the start. Word-boundary matching, not a real
|
|
// SQL parser -- same tradeoffs as the Phase 0/1 version this replaces.
|
|
var disallowedKeyword = regexp.MustCompile(`(?i)\b(insert|update|delete|alter|drop|truncate|create|grant|revoke|attach|detach|rename|kill|optimize|system|set|exchange|watch)\b`)
|
|
|
|
func validateSelectOnly(sql string) error {
|
|
trimmed := strings.TrimSpace(sql)
|
|
if trimmed == "" {
|
|
return fmt.Errorf("query must not be empty")
|
|
}
|
|
|
|
trimmed = strings.TrimSpace(strings.TrimSuffix(trimmed, ";"))
|
|
if trimmed == "" {
|
|
return fmt.Errorf("query must not be empty")
|
|
}
|
|
if strings.Contains(trimmed, ";") {
|
|
return fmt.Errorf("only a single statement is allowed")
|
|
}
|
|
|
|
firstWord := strings.ToUpper(strings.Fields(trimmed)[0])
|
|
if firstWord != "SELECT" {
|
|
return fmt.Errorf("only SELECT queries are allowed")
|
|
}
|
|
|
|
if disallowedKeyword.MatchString(trimmed) {
|
|
return fmt.Errorf("query contains a disallowed keyword")
|
|
}
|
|
|
|
return nil
|
|
}
|