Phase 4: real per-tenant ClickHouse isolation via a new enterprise-api binary
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.
This commit is contained in:
@@ -1,80 +0,0 @@
|
||||
// Package authz is core's RBAC extension point -- deliberately minimal
|
||||
// and tenant-agnostic in shape, matching queryapi.AuditLogger's pattern:
|
||||
// core defines the interface and the types, enterprise/ supplies the
|
||||
// implementation. Unlike AuditLogger, the implementation Authorizer
|
||||
// wires in here is NOT enterprise Go code injected directly (that would
|
||||
// require api to import enterprise/, violating the module boundary
|
||||
// confirmed in /docs/phase-4-isolation-design.md) -- it's an HTTP client
|
||||
// to enterprise-auth's /internal/authorize endpoint (see httpauthz.go),
|
||||
// the same "network boundary, not import boundary" pattern already
|
||||
// established between /api and /alerting.
|
||||
package authz
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// Role is ordered for human roles (Viewer < Editor < Admin < Owner, per
|
||||
// /docs/phase-4-rbac-design.md) plus a separate, non-comparable Service
|
||||
// lane for machine callers like /alerting's evaluator -- see Satisfies.
|
||||
type Role string
|
||||
|
||||
const (
|
||||
RoleViewer Role = "viewer"
|
||||
RoleEditor Role = "editor"
|
||||
RoleAdmin Role = "admin"
|
||||
RoleOwner Role = "owner"
|
||||
RoleService Role = "service"
|
||||
)
|
||||
|
||||
var roleRank = map[Role]int{RoleViewer: 1, RoleEditor: 2, RoleAdmin: 3, RoleOwner: 4}
|
||||
|
||||
// Satisfies reports whether this role meets a requirement. RoleService
|
||||
// only ever satisfies RoleService -- a service credential never
|
||||
// satisfies a human-role requirement, and a human role never satisfies
|
||||
// a RoleService requirement, by design: /docs/phase-4-isolation-design.md's
|
||||
// alerting service identity is deliberately not a point on the human
|
||||
// role scale, so it can't accidentally inherit broader access by
|
||||
// ranking above Viewer.
|
||||
func (r Role) Satisfies(required Role) bool {
|
||||
if required == RoleService || r == RoleService {
|
||||
return r == required
|
||||
}
|
||||
return roleRank[r] >= roleRank[required]
|
||||
}
|
||||
|
||||
// Identity is what a successful Authorize call resolves. UserID is
|
||||
// empty for RoleService (see /docs/phase-4-isolation-design.md's
|
||||
// alerting↔api gap -- a service credential proves "this caller is
|
||||
// alerting," not "this caller is acting as a specific human").
|
||||
type Identity struct {
|
||||
TenantID string
|
||||
UserID string
|
||||
Role Role
|
||||
}
|
||||
|
||||
// Authorizer resolves an Identity from an incoming request's
|
||||
// credentials (session cookie or service token) without knowing
|
||||
// anything about *what* the caller is trying to do -- permission
|
||||
// checking against a required Role happens in the middleware
|
||||
// (middleware.go), not here, so this interface stays a pure
|
||||
// "who is this" question.
|
||||
type Authorizer interface {
|
||||
Authorize(r *http.Request) (Identity, error)
|
||||
}
|
||||
|
||||
type identityContextKey struct{}
|
||||
|
||||
// IdentityFromContext lets a handler read the resolved identity a
|
||||
// RequireRole/RequireRoleOrService middleware attached -- e.g. to
|
||||
// populate QueryAuditEntry's tenant/user once Phase 4's audit wiring
|
||||
// threads identity through (see queryapi.AuditLogger's doc comment).
|
||||
func IdentityFromContext(ctx context.Context) (Identity, bool) {
|
||||
id, ok := ctx.Value(identityContextKey{}).(Identity)
|
||||
return id, ok
|
||||
}
|
||||
|
||||
func withIdentity(ctx context.Context, id Identity) context.Context {
|
||||
return context.WithValue(ctx, identityContextKey{}, id)
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
package authz
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestRoleSatisfies(t *testing.T) {
|
||||
tests := []struct {
|
||||
have, want Role
|
||||
satisfies bool
|
||||
}{
|
||||
{RoleViewer, RoleViewer, true},
|
||||
{RoleEditor, RoleViewer, true},
|
||||
{RoleAdmin, RoleViewer, true},
|
||||
{RoleOwner, RoleViewer, true},
|
||||
{RoleViewer, RoleEditor, false},
|
||||
{RoleViewer, RoleAdmin, false},
|
||||
{RoleEditor, RoleAdmin, false},
|
||||
{RoleAdmin, RoleOwner, false},
|
||||
{RoleOwner, RoleOwner, true},
|
||||
// RoleService is a separate lane, not on the human rank scale --
|
||||
// per /docs/phase-4-isolation-design.md's alerting service
|
||||
// identity, it must never satisfy a human role requirement no
|
||||
// matter how "high" that might look on paper, and a human role
|
||||
// (even Owner) must never satisfy a RoleService requirement.
|
||||
{RoleService, RoleViewer, false},
|
||||
{RoleService, RoleOwner, false},
|
||||
{RoleOwner, RoleService, false},
|
||||
{RoleViewer, RoleService, false},
|
||||
{RoleService, RoleService, true},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
got := tt.have.Satisfies(tt.want)
|
||||
if got != tt.satisfies {
|
||||
t.Errorf("Role(%q).Satisfies(%q) = %v, want %v", tt.have, tt.want, got, tt.satisfies)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
package authz
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
// HTTPAuthorizer is the production Authorizer for a deployment with
|
||||
// enterprise-auth configured -- it calls enterprise-auth's
|
||||
// POST /internal/authorize endpoint, forwarding the caller's own
|
||||
// credentials (session cookie or service-token header), rather than
|
||||
// importing any enterprise/ Go package. This is the same "network
|
||||
// boundary, not import boundary" shape /alerting's queryclient already
|
||||
// uses to call api's /query: the module-boundary guarantee
|
||||
// hack/check-tenant-boundary.sh enforces is about Go imports, and this
|
||||
// type has none from enterprise/.
|
||||
type HTTPAuthorizer struct {
|
||||
baseURL string
|
||||
http *http.Client
|
||||
}
|
||||
|
||||
func NewHTTPAuthorizer(baseURL string) *HTTPAuthorizer {
|
||||
return &HTTPAuthorizer{baseURL: baseURL, http: &http.Client{Timeout: 3 * time.Second}}
|
||||
}
|
||||
|
||||
type authorizeResponse struct {
|
||||
TenantID string `json:"tenant_id"`
|
||||
UserID string `json:"user_id"`
|
||||
Role string `json:"role"`
|
||||
}
|
||||
|
||||
// Authorize forwards exactly two credential-carrying headers -- Cookie
|
||||
// (human sessions) and Authorization (service tokens) -- never the rest
|
||||
// of the request. enterprise-auth validates whichever is present and
|
||||
// returns the resolved identity, or a non-2xx if neither validates.
|
||||
func (a *HTTPAuthorizer) Authorize(r *http.Request) (Identity, error) {
|
||||
req, err := http.NewRequestWithContext(r.Context(), http.MethodPost, a.baseURL+"/internal/authorize", nil)
|
||||
if err != nil {
|
||||
return Identity{}, fmt.Errorf("authz: building request: %w", err)
|
||||
}
|
||||
if cookie := r.Header.Get("Cookie"); cookie != "" {
|
||||
req.Header.Set("Cookie", cookie)
|
||||
}
|
||||
if auth := r.Header.Get("Authorization"); auth != "" {
|
||||
req.Header.Set("Authorization", auth)
|
||||
}
|
||||
|
||||
resp, err := a.http.Do(req)
|
||||
if err != nil {
|
||||
return Identity{}, fmt.Errorf("authz: calling enterprise-auth: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return Identity{}, fmt.Errorf("authz: enterprise-auth returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var body authorizeResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
|
||||
return Identity{}, fmt.Errorf("authz: decoding response: %w", err)
|
||||
}
|
||||
return Identity{TenantID: body.TenantID, UserID: body.UserID, Role: Role(body.Role)}, nil
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
package authz
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestHTTPAuthorizerForwardsCredentialsAndParsesIdentity(t *testing.T) {
|
||||
var gotCookie, gotAuth string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotCookie = r.Header.Get("Cookie")
|
||||
gotAuth = r.Header.Get("Authorization")
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(authorizeResponse{TenantID: "acme", UserID: "u1", Role: "editor"})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
a := NewHTTPAuthorizer(srv.URL)
|
||||
incoming := httptest.NewRequest(http.MethodPost, "/query", nil)
|
||||
incoming.Header.Set("Cookie", "sentry_session=abc123")
|
||||
incoming.Header.Set("Authorization", "Bearer service-token-xyz")
|
||||
|
||||
identity, err := a.Authorize(incoming)
|
||||
if err != nil {
|
||||
t.Fatalf("Authorize: %v", err)
|
||||
}
|
||||
if identity.TenantID != "acme" || identity.UserID != "u1" || identity.Role != RoleEditor {
|
||||
t.Fatalf("unexpected identity: %+v", identity)
|
||||
}
|
||||
if gotCookie != "sentry_session=abc123" {
|
||||
t.Fatalf("Cookie header not forwarded, got %q", gotCookie)
|
||||
}
|
||||
if gotAuth != "Bearer service-token-xyz" {
|
||||
t.Fatalf("Authorization header not forwarded, got %q", gotAuth)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTPAuthorizerNon2xxIsAnError(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
a := NewHTTPAuthorizer(srv.URL)
|
||||
_, err := a.Authorize(httptest.NewRequest(http.MethodPost, "/query", nil))
|
||||
if err == nil {
|
||||
t.Fatalf("expected an error for a 401 response from enterprise-auth")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTPAuthorizerDoesNotForwardUnrelatedHeaders(t *testing.T) {
|
||||
var gotXForwarded string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotXForwarded = r.Header.Get("X-Forwarded-For")
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(authorizeResponse{Role: "viewer"})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
a := NewHTTPAuthorizer(srv.URL)
|
||||
incoming := httptest.NewRequest(http.MethodPost, "/query", nil)
|
||||
incoming.Header.Set("X-Forwarded-For", "1.2.3.4")
|
||||
|
||||
if _, err := a.Authorize(incoming); err != nil {
|
||||
t.Fatalf("Authorize: %v", err)
|
||||
}
|
||||
if gotXForwarded != "" {
|
||||
t.Fatalf("expected only Cookie/Authorization to be forwarded, but X-Forwarded-For leaked through as %q", gotXForwarded)
|
||||
}
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
package authz
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
type errorResponse struct {
|
||||
Error string `json:"error"`
|
||||
}
|
||||
|
||||
func writeUnauthorized(w http.ResponseWriter) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
_ = json.NewEncoder(w).Encode(errorResponse{Error: "unauthorized"})
|
||||
}
|
||||
|
||||
func writeForbidden(w http.ResponseWriter) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
_ = json.NewEncoder(w).Encode(errorResponse{Error: "forbidden"})
|
||||
}
|
||||
|
||||
// RequireRole wraps next so it only runs for a caller whose resolved
|
||||
// Identity.Role satisfies minRole. A nil authorizer is a deliberate,
|
||||
// documented no-op -- a single-tenant deployment with no enterprise/
|
||||
// configured behaves exactly as Phases 0-3 did, unauthenticated, not
|
||||
// locked out. This is the same nil-safety shape as
|
||||
// queryapi.AuditLogger and dashboards' optional dependencies.
|
||||
func RequireRole(authorizer Authorizer, minRole Role, next http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if authorizer == nil {
|
||||
next(w, r)
|
||||
return
|
||||
}
|
||||
identity, err := authorizer.Authorize(r)
|
||||
if err != nil {
|
||||
writeUnauthorized(w)
|
||||
return
|
||||
}
|
||||
if !identity.Role.Satisfies(minRole) {
|
||||
writeForbidden(w)
|
||||
return
|
||||
}
|
||||
next(w, r.WithContext(withIdentity(r.Context(), identity)))
|
||||
}
|
||||
}
|
||||
|
||||
// RequireRoleOrService is RequireRole plus an explicit allowance for
|
||||
// RoleService -- used only by endpoints /alerting's evaluator legitimately
|
||||
// calls (POST /query today). Every other endpoint uses plain RequireRole,
|
||||
// so a service credential can never reach dashboard/rule administration
|
||||
// even though it's a valid, authenticated identity -- narrow by default,
|
||||
// widened only where a real machine caller exists.
|
||||
func RequireRoleOrService(authorizer Authorizer, minRole Role, next http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if authorizer == nil {
|
||||
next(w, r)
|
||||
return
|
||||
}
|
||||
identity, err := authorizer.Authorize(r)
|
||||
if err != nil {
|
||||
writeUnauthorized(w)
|
||||
return
|
||||
}
|
||||
if identity.Role != RoleService && !identity.Role.Satisfies(minRole) {
|
||||
writeForbidden(w)
|
||||
return
|
||||
}
|
||||
next(w, r.WithContext(withIdentity(r.Context(), identity)))
|
||||
}
|
||||
}
|
||||
@@ -1,96 +0,0 @@
|
||||
package authz
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type fakeAuthorizer struct {
|
||||
identity Identity
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *fakeAuthorizer) Authorize(_ *http.Request) (Identity, error) {
|
||||
return f.identity, f.err
|
||||
}
|
||||
|
||||
func okHandler(w http.ResponseWriter, r *http.Request) {
|
||||
id, ok := IdentityFromContext(r.Context())
|
||||
if ok {
|
||||
w.Header().Set("X-Test-Tenant", id.TenantID)
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
func TestRequireRoleNilAuthorizerIsNoOp(t *testing.T) {
|
||||
h := RequireRole(nil, RoleAdmin, okHandler)
|
||||
rec := httptest.NewRecorder()
|
||||
h(rec, httptest.NewRequest(http.MethodGet, "/", nil))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200 (nil authorizer must be a no-op, matching single-tenant Phase 0-3 behavior)", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequireRoleRejectsUnauthenticated(t *testing.T) {
|
||||
h := RequireRole(&fakeAuthorizer{err: errors.New("no session")}, RoleViewer, okHandler)
|
||||
rec := httptest.NewRecorder()
|
||||
h(rec, httptest.NewRequest(http.MethodGet, "/", nil))
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("status = %d, want 401", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequireRoleRejectsInsufficientRole(t *testing.T) {
|
||||
h := RequireRole(&fakeAuthorizer{identity: Identity{Role: RoleViewer}}, RoleAdmin, okHandler)
|
||||
rec := httptest.NewRecorder()
|
||||
h(rec, httptest.NewRequest(http.MethodGet, "/", nil))
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("status = %d, want 403", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequireRoleAllowsSufficientRoleAndAttachesIdentity(t *testing.T) {
|
||||
h := RequireRole(&fakeAuthorizer{identity: Identity{TenantID: "acme", Role: RoleAdmin}}, RoleEditor, okHandler)
|
||||
rec := httptest.NewRecorder()
|
||||
h(rec, httptest.NewRequest(http.MethodGet, "/", nil))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", rec.Code)
|
||||
}
|
||||
if got := rec.Header().Get("X-Test-Tenant"); got != "acme" {
|
||||
t.Fatalf("expected the resolved identity to be attached to the request context, got tenant=%q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequireRoleOrServiceAllowsServiceIdentity(t *testing.T) {
|
||||
h := RequireRoleOrService(&fakeAuthorizer{identity: Identity{Role: RoleService}}, RoleAdmin, okHandler)
|
||||
rec := httptest.NewRecorder()
|
||||
h(rec, httptest.NewRequest(http.MethodGet, "/", nil))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200 -- RoleService must satisfy RequireRoleOrService regardless of minRole", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequireRoleOrServiceStillRejectsInsufficientHumanRole(t *testing.T) {
|
||||
h := RequireRoleOrService(&fakeAuthorizer{identity: Identity{Role: RoleViewer}}, RoleAdmin, okHandler)
|
||||
rec := httptest.NewRecorder()
|
||||
h(rec, httptest.NewRequest(http.MethodGet, "/", nil))
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("status = %d, want 403 -- allowing RoleService must not loosen the human-role check", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRequireRolePlainDoesNotAllowService pins down the narrow-by-default
|
||||
// property middleware.go's doc comment claims: an endpoint using plain
|
||||
// RequireRole (not RequireRoleOrService) must reject a RoleService
|
||||
// identity even if the minRole would technically be satisfiable on the
|
||||
// human scale -- RoleService never satisfies anything but itself.
|
||||
func TestRequireRolePlainDoesNotAllowService(t *testing.T) {
|
||||
h := RequireRole(&fakeAuthorizer{identity: Identity{Role: RoleService}}, RoleViewer, okHandler)
|
||||
rec := httptest.NewRecorder()
|
||||
h(rec, httptest.NewRequest(http.MethodGet, "/", nil))
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("status = %d, want 403 -- plain RequireRole must never admit a service identity", rec.Code)
|
||||
}
|
||||
}
|
||||
@@ -1,246 +0,0 @@
|
||||
package dashboards
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"github.com/sentry/sentry/api/internal/authz"
|
||||
)
|
||||
|
||||
// store is the narrow interface Handler depends on -- *Store (store.go)
|
||||
// is the production implementation; tests use a fake, same pattern as
|
||||
// queryapi's SQLRunner/SearchClient. Every method except Create/Import
|
||||
// takes a tenantID -- see store.go's doc comment for why.
|
||||
type store interface {
|
||||
CreateDashboard(ctx context.Context, d *Dashboard) error
|
||||
ListDashboards(ctx context.Context, tenantID string) ([]Dashboard, error)
|
||||
GetDashboard(ctx context.Context, tenantID, id string) (*Dashboard, error)
|
||||
UpdateDashboard(ctx context.Context, tenantID string, d *Dashboard) error
|
||||
DeleteDashboard(ctx context.Context, tenantID, id string) error
|
||||
AddPanel(ctx context.Context, tenantID, dashboardID string, p *Panel) error
|
||||
UpdatePanel(ctx context.Context, tenantID string, p *Panel) error
|
||||
DeletePanel(ctx context.Context, tenantID, dashboardID, panelID string) error
|
||||
ImportDashboard(ctx context.Context, tenantID string, d *Dashboard) (*Dashboard, error)
|
||||
}
|
||||
|
||||
type Handler struct {
|
||||
logger *slog.Logger
|
||||
store store
|
||||
authorizer authz.Authorizer
|
||||
}
|
||||
|
||||
func NewHandler(logger *slog.Logger, store store, authorizer authz.Authorizer) *Handler {
|
||||
return &Handler{logger: logger, store: store, authorizer: authorizer}
|
||||
}
|
||||
|
||||
// RegisterRoutes wires the RBAC minimum-role bar from
|
||||
// /docs/phase-4-rbac-design.md's matrix. Note what's NOT enforced here
|
||||
// yet: the matrix's "(own/granted)" qualifier for Editor create/edit/
|
||||
// delete requires the dashboard_permissions/ownership lookup that
|
||||
// enterprise/internal/rbacstore hasn't been built yet -- until then,
|
||||
// RoleEditor is necessary but not sufficient per the matrix, and every
|
||||
// Editor can act on every dashboard *within their own tenant* (tenant
|
||||
// scoping itself -- a different, more basic property than the
|
||||
// per-resource "(own/granted)" qualifier -- is enforced, via tenantID
|
||||
// below and store.go's WHERE tenant_id = ... filtering). Tracked as
|
||||
// follow-up, not silently dropped.
|
||||
func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
|
||||
mux.HandleFunc("POST /dashboards", authz.RequireRole(h.authorizer, authz.RoleEditor, h.handleCreate))
|
||||
mux.HandleFunc("GET /dashboards", authz.RequireRole(h.authorizer, authz.RoleViewer, h.handleList))
|
||||
mux.HandleFunc("POST /dashboards/import", authz.RequireRole(h.authorizer, authz.RoleEditor, h.handleImport))
|
||||
mux.HandleFunc("GET /dashboards/{id}", authz.RequireRole(h.authorizer, authz.RoleViewer, h.handleGet))
|
||||
mux.HandleFunc("PUT /dashboards/{id}", authz.RequireRole(h.authorizer, authz.RoleEditor, h.handleUpdate))
|
||||
mux.HandleFunc("DELETE /dashboards/{id}", authz.RequireRole(h.authorizer, authz.RoleEditor, h.handleDelete))
|
||||
mux.HandleFunc("GET /dashboards/{id}/export", authz.RequireRole(h.authorizer, authz.RoleViewer, h.handleExport))
|
||||
mux.HandleFunc("POST /dashboards/{id}/panels", authz.RequireRole(h.authorizer, authz.RoleEditor, h.handleAddPanel))
|
||||
mux.HandleFunc("PUT /dashboards/{id}/panels/{panelId}", authz.RequireRole(h.authorizer, authz.RoleEditor, h.handleUpdatePanel))
|
||||
mux.HandleFunc("DELETE /dashboards/{id}/panels/{panelId}", authz.RequireRole(h.authorizer, authz.RoleEditor, h.handleDeletePanel))
|
||||
}
|
||||
|
||||
const maxBodyBytes = 1 << 20 // 1 MiB, same cap as queryapi
|
||||
|
||||
// tenantID resolves the tenant to scope a request's store calls to --
|
||||
// from the authenticated identity RequireRole attached to the request
|
||||
// context, never from a client-supplied field (a Dashboard JSON body
|
||||
// can set "tenant_id" to anything; store.go's methods only ever see the
|
||||
// value this function returns, not that field). Falls back to
|
||||
// "default" when no identity is present (nil authorizer -- matches
|
||||
// every other nil-authorizer-is-Phase-0-3-single-tenant default in this
|
||||
// codebase) or when a resolved identity somehow carries no tenant (only
|
||||
// RoleService identities can, per authz.Identity's doc comment, and
|
||||
// RequireRole -- unlike RequireRoleOrService -- never admits RoleService,
|
||||
// so this branch is a defensive fallback, not an expected path).
|
||||
func (h *Handler) tenantID(r *http.Request) string {
|
||||
if id, ok := authz.IdentityFromContext(r.Context()); ok && id.TenantID != "" {
|
||||
return id.TenantID
|
||||
}
|
||||
return "default"
|
||||
}
|
||||
|
||||
func (h *Handler) handleCreate(w http.ResponseWriter, r *http.Request) {
|
||||
var d Dashboard
|
||||
if !decodeJSON(w, r, &d) {
|
||||
return
|
||||
}
|
||||
if d.Name == "" {
|
||||
writeError(w, http.StatusBadRequest, "name must not be empty")
|
||||
return
|
||||
}
|
||||
// Overrides any client-supplied tenant_id -- see tenantID's doc comment.
|
||||
d.TenantID = h.tenantID(r)
|
||||
if err := h.store.CreateDashboard(r.Context(), &d); err != nil {
|
||||
h.logger.Error("creating dashboard", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "creating dashboard failed")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, d)
|
||||
}
|
||||
|
||||
func (h *Handler) handleList(w http.ResponseWriter, r *http.Request) {
|
||||
list, err := h.store.ListDashboards(r.Context(), h.tenantID(r))
|
||||
if err != nil {
|
||||
h.logger.Error("listing dashboards", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "listing dashboards failed")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, list)
|
||||
}
|
||||
|
||||
func (h *Handler) handleGet(w http.ResponseWriter, r *http.Request) {
|
||||
d, err := h.store.GetDashboard(r.Context(), h.tenantID(r), r.PathValue("id"))
|
||||
if err != nil {
|
||||
h.writeStoreErr(w, err, "fetching dashboard")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, d)
|
||||
}
|
||||
|
||||
func (h *Handler) handleExport(w http.ResponseWriter, r *http.Request) {
|
||||
// Export is the same document GET /dashboards/{id} returns -- the
|
||||
// import endpoint below consumes exactly this shape, and so does
|
||||
// `sentryctl dashboards apply`, so there's one JSON contract used
|
||||
// from every call site rather than a bespoke export format.
|
||||
h.handleGet(w, r)
|
||||
}
|
||||
|
||||
func (h *Handler) handleImport(w http.ResponseWriter, r *http.Request) {
|
||||
var d Dashboard
|
||||
if !decodeJSON(w, r, &d) {
|
||||
return
|
||||
}
|
||||
if d.Name == "" {
|
||||
writeError(w, http.StatusBadRequest, "name must not be empty")
|
||||
return
|
||||
}
|
||||
imported, err := h.store.ImportDashboard(r.Context(), h.tenantID(r), &d)
|
||||
if err != nil {
|
||||
h.logger.Error("importing dashboard", "error", err)
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, imported)
|
||||
}
|
||||
|
||||
func (h *Handler) handleUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
var d Dashboard
|
||||
if !decodeJSON(w, r, &d) {
|
||||
return
|
||||
}
|
||||
if d.Name == "" {
|
||||
writeError(w, http.StatusBadRequest, "name must not be empty")
|
||||
return
|
||||
}
|
||||
d.ID = r.PathValue("id")
|
||||
if err := h.store.UpdateDashboard(r.Context(), h.tenantID(r), &d); err != nil {
|
||||
h.writeStoreErr(w, err, "updating dashboard")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, d)
|
||||
}
|
||||
|
||||
func (h *Handler) handleDelete(w http.ResponseWriter, r *http.Request) {
|
||||
if err := h.store.DeleteDashboard(r.Context(), h.tenantID(r), r.PathValue("id")); err != nil {
|
||||
h.writeStoreErr(w, err, "deleting dashboard")
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (h *Handler) handleAddPanel(w http.ResponseWriter, r *http.Request) {
|
||||
var p Panel
|
||||
if !decodeJSON(w, r, &p) {
|
||||
return
|
||||
}
|
||||
if err := validatePanel(&p); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
if err := h.store.AddPanel(r.Context(), h.tenantID(r), r.PathValue("id"), &p); err != nil {
|
||||
h.writeStoreErr(w, err, "adding panel")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, p)
|
||||
}
|
||||
|
||||
func (h *Handler) handleUpdatePanel(w http.ResponseWriter, r *http.Request) {
|
||||
var p Panel
|
||||
if !decodeJSON(w, r, &p) {
|
||||
return
|
||||
}
|
||||
if err := validatePanel(&p); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
p.ID = r.PathValue("panelId")
|
||||
p.DashboardID = r.PathValue("id")
|
||||
if err := h.store.UpdatePanel(r.Context(), h.tenantID(r), &p); err != nil {
|
||||
h.writeStoreErr(w, err, "updating panel")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, p)
|
||||
}
|
||||
|
||||
func (h *Handler) handleDeletePanel(w http.ResponseWriter, r *http.Request) {
|
||||
if err := h.store.DeletePanel(r.Context(), h.tenantID(r), r.PathValue("id"), r.PathValue("panelId")); err != nil {
|
||||
h.writeStoreErr(w, err, "deleting panel")
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (h *Handler) writeStoreErr(w http.ResponseWriter, err error, action string) {
|
||||
if errors.Is(err, ErrNotFound) {
|
||||
writeError(w, http.StatusNotFound, "not found")
|
||||
return
|
||||
}
|
||||
h.logger.Error(action, "error", err)
|
||||
writeError(w, http.StatusInternalServerError, action+" failed")
|
||||
}
|
||||
|
||||
func decodeJSON(w http.ResponseWriter, r *http.Request, v any) bool {
|
||||
r.Body = http.MaxBytesReader(w, r.Body, maxBodyBytes)
|
||||
if err := json.NewDecoder(r.Body).Decode(v); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid JSON body: "+err.Error())
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
|
||||
type errorResponse struct {
|
||||
Error string `json:"error"`
|
||||
}
|
||||
|
||||
func writeError(w http.ResponseWriter, status int, msg string) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(errorResponse{Error: msg})
|
||||
}
|
||||
@@ -1,464 +0,0 @@
|
||||
package dashboards
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/sentry/sentry/api/internal/authz"
|
||||
)
|
||||
|
||||
// fakeStore enforces tenant scoping the same way store.go's real
|
||||
// pgx-backed Store does (a mismatched tenantID behaves exactly like a
|
||||
// missing ID -- ErrNotFound, never a distinguishable "found but wrong
|
||||
// tenant" error) so handler_test.go's cross-tenant tests exercise real
|
||||
// behavior, not a fake that happens to always agree.
|
||||
type fakeStore struct {
|
||||
dashboards map[string]*Dashboard
|
||||
createErr error
|
||||
importErr error
|
||||
}
|
||||
|
||||
func newFakeStore() *fakeStore {
|
||||
return &fakeStore{dashboards: map[string]*Dashboard{}}
|
||||
}
|
||||
|
||||
func (f *fakeStore) CreateDashboard(_ context.Context, d *Dashboard) error {
|
||||
if f.createErr != nil {
|
||||
return f.createErr
|
||||
}
|
||||
d.ID = "dash-1"
|
||||
f.dashboards[d.ID] = d
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) ListDashboards(_ context.Context, tenantID string) ([]Dashboard, error) {
|
||||
var out []Dashboard
|
||||
for _, d := range f.dashboards {
|
||||
if d.TenantID == tenantID {
|
||||
out = append(out, *d)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) GetDashboard(_ context.Context, tenantID, id string) (*Dashboard, error) {
|
||||
d, ok := f.dashboards[id]
|
||||
if !ok || d.TenantID != tenantID {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return d, nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) UpdateDashboard(_ context.Context, tenantID string, d *Dashboard) error {
|
||||
existing, ok := f.dashboards[d.ID]
|
||||
if !ok || existing.TenantID != tenantID {
|
||||
return ErrNotFound
|
||||
}
|
||||
panels := existing.Panels
|
||||
*existing = *d
|
||||
existing.TenantID = tenantID
|
||||
existing.Panels = panels
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) DeleteDashboard(_ context.Context, tenantID, id string) error {
|
||||
d, ok := f.dashboards[id]
|
||||
if !ok || d.TenantID != tenantID {
|
||||
return ErrNotFound
|
||||
}
|
||||
delete(f.dashboards, id)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) AddPanel(_ context.Context, tenantID, dashboardID string, p *Panel) error {
|
||||
d, ok := f.dashboards[dashboardID]
|
||||
if !ok || d.TenantID != tenantID {
|
||||
return ErrNotFound
|
||||
}
|
||||
p.ID = "panel-1"
|
||||
p.DashboardID = dashboardID
|
||||
d.Panels = append(d.Panels, *p)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) UpdatePanel(_ context.Context, tenantID string, p *Panel) error {
|
||||
d, ok := f.dashboards[p.DashboardID]
|
||||
if !ok || d.TenantID != tenantID {
|
||||
return ErrNotFound
|
||||
}
|
||||
for i := range d.Panels {
|
||||
if d.Panels[i].ID == p.ID {
|
||||
d.Panels[i] = *p
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return ErrNotFound
|
||||
}
|
||||
|
||||
func (f *fakeStore) DeletePanel(_ context.Context, tenantID, dashboardID, panelID string) error {
|
||||
d, ok := f.dashboards[dashboardID]
|
||||
if !ok || d.TenantID != tenantID {
|
||||
return ErrNotFound
|
||||
}
|
||||
for i := range d.Panels {
|
||||
if d.Panels[i].ID == panelID {
|
||||
d.Panels = append(d.Panels[:i], d.Panels[i+1:]...)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return ErrNotFound
|
||||
}
|
||||
|
||||
func (f *fakeStore) ImportDashboard(_ context.Context, tenantID string, d *Dashboard) (*Dashboard, error) {
|
||||
if f.importErr != nil {
|
||||
return nil, f.importErr
|
||||
}
|
||||
imported := *d
|
||||
imported.ID = "dash-imported"
|
||||
imported.TenantID = tenantID
|
||||
f.dashboards[imported.ID] = &imported
|
||||
return &imported, nil
|
||||
}
|
||||
|
||||
func newTestMux(fs *fakeStore) *http.ServeMux {
|
||||
h := NewHandler(slog.New(slog.NewTextHandler(io.Discard, nil)), fs, nil)
|
||||
mux := http.NewServeMux()
|
||||
h.RegisterRoutes(mux)
|
||||
return mux
|
||||
}
|
||||
|
||||
// fakeAuthorizer resolves every request to a fixed identity -- used by
|
||||
// the cross-tenant tests below, which need a real (non-nil) authorizer
|
||||
// so tenantID(r) reads from the resolved identity instead of falling
|
||||
// back to "default" for every request.
|
||||
type fakeAuthorizer struct {
|
||||
identity authz.Identity
|
||||
}
|
||||
|
||||
func (f *fakeAuthorizer) Authorize(*http.Request) (authz.Identity, error) {
|
||||
return f.identity, nil
|
||||
}
|
||||
|
||||
func newTestMuxWithTenant(fs *fakeStore, tenantID string) *http.ServeMux {
|
||||
h := NewHandler(slog.New(slog.NewTextHandler(io.Discard, nil)), fs,
|
||||
&fakeAuthorizer{identity: authz.Identity{TenantID: tenantID, Role: authz.RoleOwner}})
|
||||
mux := http.NewServeMux()
|
||||
h.RegisterRoutes(mux)
|
||||
return mux
|
||||
}
|
||||
|
||||
func doRequest(t *testing.T, mux *http.ServeMux, method, path, body string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
var r io.Reader
|
||||
if body != "" {
|
||||
r = strings.NewReader(body)
|
||||
}
|
||||
req := httptest.NewRequest(method, path, r)
|
||||
rec := httptest.NewRecorder()
|
||||
mux.ServeHTTP(rec, req)
|
||||
return rec
|
||||
}
|
||||
|
||||
func TestCreateDashboard(t *testing.T) {
|
||||
mux := newTestMux(newFakeStore())
|
||||
rec := doRequest(t, mux, http.MethodPost, "/dashboards", `{"name": "Overview"}`)
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("status = %d, want 201; body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var got Dashboard
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
|
||||
t.Fatalf("decoding response: %v", err)
|
||||
}
|
||||
if got.ID == "" {
|
||||
t.Fatalf("expected an assigned ID, got empty")
|
||||
}
|
||||
}
|
||||
|
||||
// TestCreateDashboardIgnoresClientSuppliedTenantID is the regression
|
||||
// test for the tenant-spoofing gap found during Phase 4 task 7 (see
|
||||
// /docs/security/threat-model.md's "application-layer tenant scoping"
|
||||
// section): Dashboard.TenantID has a `json:"tenant_id"` tag, so a
|
||||
// request body can set it to anything. The handler must always
|
||||
// overwrite it from the authenticated identity, never trust the body.
|
||||
func TestCreateDashboardIgnoresClientSuppliedTenantID(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
mux := newTestMuxWithTenant(fs, "acme")
|
||||
|
||||
rec := doRequest(t, mux, http.MethodPost, "/dashboards", `{"name": "Overview", "tenant_id": "globex"}`)
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("status = %d, want 201; body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if fs.dashboards["dash-1"].TenantID != "acme" {
|
||||
t.Fatalf("stored TenantID = %q, want %q (the authenticated identity's tenant, not the client-supplied value)", fs.dashboards["dash-1"].TenantID, "acme")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateDashboardRejectsEmptyName(t *testing.T) {
|
||||
mux := newTestMux(newFakeStore())
|
||||
rec := doRequest(t, mux, http.MethodPost, "/dashboards", `{"name": ""}`)
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want 400", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetDashboardNotFound(t *testing.T) {
|
||||
mux := newTestMux(newFakeStore())
|
||||
rec := doRequest(t, mux, http.MethodGet, "/dashboards/nope", "")
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("status = %d, want 404", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCrossTenantGetIsNotFound is the core adversarial case: a request
|
||||
// authenticated as tenant "globex" must not be able to read a dashboard
|
||||
// that belongs to tenant "acme" -- and the response must be a plain 404
|
||||
// (not a 403, which would confirm the ID exists under a different
|
||||
// tenant).
|
||||
func TestCrossTenantGetIsNotFound(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
fs.dashboards["dash-1"] = &Dashboard{ID: "dash-1", TenantID: "acme", Name: "Acme's dashboard"}
|
||||
mux := newTestMuxWithTenant(fs, "globex")
|
||||
|
||||
rec := doRequest(t, mux, http.MethodGet, "/dashboards/dash-1", "")
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("status = %d, want 404 (cross-tenant read must not succeed or leak existence via a different status)", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossTenantListDoesNotLeakOtherTenants(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
fs.dashboards["dash-1"] = &Dashboard{ID: "dash-1", TenantID: "acme", Name: "Acme's dashboard"}
|
||||
fs.dashboards["dash-2"] = &Dashboard{ID: "dash-2", TenantID: "globex", Name: "Globex's dashboard"}
|
||||
mux := newTestMuxWithTenant(fs, "globex")
|
||||
|
||||
rec := doRequest(t, mux, http.MethodGet, "/dashboards", "")
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var got []Dashboard
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
|
||||
t.Fatalf("decoding response: %v", err)
|
||||
}
|
||||
if len(got) != 1 || got[0].ID != "dash-2" {
|
||||
t.Fatalf("expected only globex's own dashboard, got %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossTenantUpdateIsNotFound(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
fs.dashboards["dash-1"] = &Dashboard{ID: "dash-1", TenantID: "acme", Name: "Acme's dashboard"}
|
||||
mux := newTestMuxWithTenant(fs, "globex")
|
||||
|
||||
rec := doRequest(t, mux, http.MethodPut, "/dashboards/dash-1", `{"name": "Hijacked"}`)
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("status = %d, want 404", rec.Code)
|
||||
}
|
||||
if fs.dashboards["dash-1"].Name != "Acme's dashboard" {
|
||||
t.Fatalf("cross-tenant update must not modify the row, got Name = %q", fs.dashboards["dash-1"].Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossTenantDeleteIsNotFound(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
fs.dashboards["dash-1"] = &Dashboard{ID: "dash-1", TenantID: "acme", Name: "Acme's dashboard"}
|
||||
mux := newTestMuxWithTenant(fs, "globex")
|
||||
|
||||
rec := doRequest(t, mux, http.MethodDelete, "/dashboards/dash-1", "")
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("status = %d, want 404", rec.Code)
|
||||
}
|
||||
if _, ok := fs.dashboards["dash-1"]; !ok {
|
||||
t.Fatalf("cross-tenant delete must not remove the row")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossTenantAddPanelIsNotFound(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
fs.dashboards["dash-1"] = &Dashboard{ID: "dash-1", TenantID: "acme", Name: "Acme's dashboard"}
|
||||
mux := newTestMuxWithTenant(fs, "globex")
|
||||
|
||||
rec := doRequest(t, mux, http.MethodPost, "/dashboards/dash-1/panels",
|
||||
`{"query": "service=api", "viz_type": "table"}`)
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("status = %d, want 404; body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if len(fs.dashboards["dash-1"].Panels) != 0 {
|
||||
t.Fatalf("cross-tenant AddPanel must not attach a panel to the other tenant's dashboard")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddPanelRejectsRawSQL(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
fs.dashboards["dash-1"] = &Dashboard{ID: "dash-1", TenantID: "default", Name: "Overview"}
|
||||
mux := newTestMux(fs)
|
||||
|
||||
rec := doRequest(t, mux, http.MethodPost, "/dashboards/dash-1/panels",
|
||||
`{"query": "SELECT 1", "query_language": "sql", "viz_type": "table"}`)
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want 400; body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddPanelRejectsInvalidVizType(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
fs.dashboards["dash-1"] = &Dashboard{ID: "dash-1", TenantID: "default", Name: "Overview"}
|
||||
mux := newTestMux(fs)
|
||||
|
||||
rec := doRequest(t, mux, http.MethodPost, "/dashboards/dash-1/panels",
|
||||
`{"query": "service=api", "viz_type": "pie"}`)
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want 400; body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddPanelSuccess(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
fs.dashboards["dash-1"] = &Dashboard{ID: "dash-1", TenantID: "default", Name: "Overview"}
|
||||
mux := newTestMux(fs)
|
||||
|
||||
rec := doRequest(t, mux, http.MethodPost, "/dashboards/dash-1/panels",
|
||||
`{"title": "Errors", "query": "service=api | stats count by host", "viz_type": "line", "width": 6, "height": 4}`)
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("status = %d, want 201; body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if len(fs.dashboards["dash-1"].Panels) != 1 {
|
||||
t.Fatalf("expected 1 panel stored, got %d", len(fs.dashboards["dash-1"].Panels))
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateDashboardChangesTimeRange(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
fs.dashboards["dash-1"] = &Dashboard{ID: "dash-1", TenantID: "default", Name: "Overview", DefaultEarliest: "-1h", DefaultLatest: "now"}
|
||||
mux := newTestMux(fs)
|
||||
|
||||
rec := doRequest(t, mux, http.MethodPut, "/dashboards/dash-1",
|
||||
`{"name": "Overview", "default_earliest": "-24h", "default_latest": "now"}`)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if fs.dashboards["dash-1"].DefaultEarliest != "-24h" {
|
||||
t.Fatalf("expected default_earliest to be updated, got %q", fs.dashboards["dash-1"].DefaultEarliest)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateDashboardNotFound(t *testing.T) {
|
||||
mux := newTestMux(newFakeStore())
|
||||
rec := doRequest(t, mux, http.MethodPut, "/dashboards/nope", `{"name": "Overview"}`)
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("status = %d, want 404", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteDashboard(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
fs.dashboards["dash-1"] = &Dashboard{ID: "dash-1", TenantID: "default", Name: "Overview"}
|
||||
mux := newTestMux(fs)
|
||||
|
||||
rec := doRequest(t, mux, http.MethodDelete, "/dashboards/dash-1", "")
|
||||
if rec.Code != http.StatusNoContent {
|
||||
t.Fatalf("status = %d, want 204", rec.Code)
|
||||
}
|
||||
if _, ok := fs.dashboards["dash-1"]; ok {
|
||||
t.Fatalf("expected dashboard to be deleted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExportThenImportRoundTrips(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
fs.dashboards["dash-1"] = &Dashboard{
|
||||
ID: "dash-1", TenantID: "default", Name: "Overview",
|
||||
Panels: []Panel{{ID: "panel-1", DashboardID: "dash-1", Query: "service=api", VizType: VizTable}},
|
||||
}
|
||||
mux := newTestMux(fs)
|
||||
|
||||
exportRec := doRequest(t, mux, http.MethodGet, "/dashboards/dash-1/export", "")
|
||||
if exportRec.Code != http.StatusOK {
|
||||
t.Fatalf("export status = %d, want 200", exportRec.Code)
|
||||
}
|
||||
|
||||
importRec := doRequest(t, mux, http.MethodPost, "/dashboards/import", exportRec.Body.String())
|
||||
if importRec.Code != http.StatusCreated {
|
||||
t.Fatalf("import status = %d, want 201; body=%s", importRec.Code, importRec.Body.String())
|
||||
}
|
||||
var imported Dashboard
|
||||
if err := json.Unmarshal(importRec.Body.Bytes(), &imported); err != nil {
|
||||
t.Fatalf("decoding import response: %v", err)
|
||||
}
|
||||
if imported.ID == "dash-1" {
|
||||
t.Fatalf("expected import to assign a fresh ID, got the source ID back")
|
||||
}
|
||||
}
|
||||
|
||||
// TestImportIgnoresExportedTenantID: an exported dashboard JSON file
|
||||
// carries whatever tenant_id it was exported from -- importing it into
|
||||
// a different tenant's session must assign it to the *importing*
|
||||
// tenant, never silently move it to the tenant named in the file.
|
||||
func TestImportIgnoresExportedTenantID(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
mux := newTestMuxWithTenant(fs, "globex")
|
||||
|
||||
rec := doRequest(t, mux, http.MethodPost, "/dashboards/import",
|
||||
`{"name": "Imported", "tenant_id": "acme"}`)
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("status = %d, want 201; body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var imported Dashboard
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &imported); err != nil {
|
||||
t.Fatalf("decoding response: %v", err)
|
||||
}
|
||||
if imported.TenantID != "globex" {
|
||||
t.Fatalf("imported TenantID = %q, want %q (the importing identity's tenant)", imported.TenantID, "globex")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateDashboardStoreErrorReturns500(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
fs.createErr = errors.New("boom")
|
||||
mux := newTestMux(fs)
|
||||
|
||||
rec := doRequest(t, mux, http.MethodPost, "/dashboards", `{"name": "Overview"}`)
|
||||
if rec.Code != http.StatusInternalServerError {
|
||||
t.Fatalf("status = %d, want 500", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestServiceIdentityCannotAccessDashboards is the other half of the
|
||||
// service-identity boundary (the /query half is
|
||||
// api/internal/queryapi's own tests) -- api/internal/authz's own tests
|
||||
// already prove RequireRole rejects RoleService in isolation
|
||||
// (TestRequireRolePlainDoesNotAllowService); this proves it holds
|
||||
// through the real dashboards handler, wired the way it's actually
|
||||
// deployed, not just the middleware function in isolation. A defect
|
||||
// here would mean /alerting's service token -- meant only for POST
|
||||
// /query -- could also read/write dashboards, which was never the
|
||||
// intent (dashboards uses plain RequireRole, not RequireRoleOrService,
|
||||
// specifically to keep this door shut).
|
||||
func TestServiceIdentityCannotAccessDashboards(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
fs.dashboards["dash-1"] = &Dashboard{ID: "dash-1", TenantID: "default", Name: "Overview"}
|
||||
h := NewHandler(slog.New(slog.NewTextHandler(io.Discard, nil)), fs,
|
||||
&fakeAuthorizer{identity: authz.Identity{Role: authz.RoleService}})
|
||||
mux := http.NewServeMux()
|
||||
h.RegisterRoutes(mux)
|
||||
|
||||
for _, tt := range []struct {
|
||||
method, path, body string
|
||||
}{
|
||||
{http.MethodGet, "/dashboards", ""},
|
||||
{http.MethodGet, "/dashboards/dash-1", ""},
|
||||
{http.MethodPost, "/dashboards", `{"name": "New"}`},
|
||||
{http.MethodDelete, "/dashboards/dash-1", ""},
|
||||
} {
|
||||
rec := doRequest(t, mux, tt.method, tt.path, tt.body)
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Errorf("%s %s: status = %d, want 403 (RoleService must never access dashboards)", tt.method, tt.path, rec.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,316 +0,0 @@
|
||||
package dashboards
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// ErrNotFound is returned by Get/Delete when the id doesn't exist --
|
||||
// including when it exists but belongs to a different tenant (see this
|
||||
// file's tenant-scoping comment below): a 404 either way, never a 403
|
||||
// that would confirm cross-tenant existence.
|
||||
var ErrNotFound = errors.New("not found")
|
||||
|
||||
// Store is the pgx-backed CRUD implementation. IDs are assigned
|
||||
// server-side (google/uuid), matching how /ingest assigns record_id --
|
||||
// one place (Go) generates IDs, not split between the app and the
|
||||
// database via a Postgres extension.
|
||||
//
|
||||
// Every method below except CreateDashboard/ImportDashboard takes a
|
||||
// tenantID and filters by it (`WHERE ... AND tenant_id = $N`, or a join
|
||||
// through dashboards for the panel methods, since dashboard_panels has
|
||||
// no tenant_id column of its own). This is Phase 4 task 5/8 tenant
|
||||
// scoping, added after the authz RBAC wiring shipped without it -- see
|
||||
// /docs/security/threat-model.md's "application-layer tenant scoping"
|
||||
// section for why that gap mattered even with RBAC live: a role check
|
||||
// alone answers "is this identity allowed to edit *some* dashboard,"
|
||||
// not "is this identity allowed to touch *this* dashboard." The
|
||||
// handler (handler.go) resolves tenantID from the authenticated
|
||||
// identity (authz.IdentityFromContext) -- never from a client-supplied
|
||||
// request field, since Dashboard.TenantID is a JSON field a request
|
||||
// body can set arbitrarily.
|
||||
type Store struct {
|
||||
pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
func NewStore(pool *pgxpool.Pool) *Store {
|
||||
return &Store{pool: pool}
|
||||
}
|
||||
|
||||
// CreateDashboard trusts d.TenantID -- callers (handler.go) must set it
|
||||
// from the authenticated identity before calling, never from client
|
||||
// input. Not itself tenant-scoped (there's nothing to scope against
|
||||
// yet; the row doesn't exist).
|
||||
func (s *Store) CreateDashboard(ctx context.Context, d *Dashboard) error {
|
||||
d.ID = uuid.NewString()
|
||||
if d.TenantID == "" {
|
||||
d.TenantID = "default"
|
||||
}
|
||||
if d.CreatedBy == "" {
|
||||
d.CreatedBy = "anonymous"
|
||||
}
|
||||
if d.DefaultEarliest == "" {
|
||||
d.DefaultEarliest = "-1h"
|
||||
}
|
||||
if d.DefaultLatest == "" {
|
||||
d.DefaultLatest = "now"
|
||||
}
|
||||
row := s.pool.QueryRow(ctx, `
|
||||
INSERT INTO dashboards (id, tenant_id, name, description, default_earliest, default_latest, created_by)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
RETURNING created_at, updated_at`,
|
||||
d.ID, d.TenantID, d.Name, d.Description, d.DefaultEarliest, d.DefaultLatest, d.CreatedBy)
|
||||
return row.Scan(&d.CreatedAt, &d.UpdatedAt)
|
||||
}
|
||||
|
||||
func (s *Store) ListDashboards(ctx context.Context, tenantID string) ([]Dashboard, error) {
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT id, tenant_id, name, description, default_earliest, default_latest, created_by, created_at, updated_at
|
||||
FROM dashboards WHERE tenant_id = $1 ORDER BY created_at DESC`, tenantID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []Dashboard
|
||||
for rows.Next() {
|
||||
var d Dashboard
|
||||
if err := rows.Scan(&d.ID, &d.TenantID, &d.Name, &d.Description, &d.DefaultEarliest, &d.DefaultLatest, &d.CreatedBy, &d.CreatedAt, &d.UpdatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, d)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) GetDashboard(ctx context.Context, tenantID, id string) (*Dashboard, error) {
|
||||
var d Dashboard
|
||||
row := s.pool.QueryRow(ctx, `
|
||||
SELECT id, tenant_id, name, description, default_earliest, default_latest, created_by, created_at, updated_at
|
||||
FROM dashboards WHERE id = $1 AND tenant_id = $2`, id, tenantID)
|
||||
if err := row.Scan(&d.ID, &d.TenantID, &d.Name, &d.Description, &d.DefaultEarliest, &d.DefaultLatest, &d.CreatedBy, &d.CreatedAt, &d.UpdatedAt); err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
panels, err := s.listPanels(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
d.Panels = panels
|
||||
return &d, nil
|
||||
}
|
||||
|
||||
// listPanels doesn't itself take a tenantID -- every call site first
|
||||
// resolves the owning dashboard via a tenant-scoped query (GetDashboard
|
||||
// above, or dashboardTenantMatches below), so by the time this runs,
|
||||
// dashboardID is already known to belong to the caller's tenant.
|
||||
func (s *Store) listPanels(ctx context.Context, dashboardID string) ([]Panel, error) {
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT id, dashboard_id, title, query, query_language, viz_type, viz_config,
|
||||
position_x, position_y, width, height, earliest_override, latest_override,
|
||||
sort_order, created_at, updated_at
|
||||
FROM dashboard_panels WHERE dashboard_id = $1 ORDER BY sort_order, created_at`, dashboardID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []Panel
|
||||
for rows.Next() {
|
||||
var p Panel
|
||||
if err := rows.Scan(&p.ID, &p.DashboardID, &p.Title, &p.Query, &p.QueryLanguage, &p.VizType, &p.VizConfig,
|
||||
&p.PositionX, &p.PositionY, &p.Width, &p.Height, &p.EarliestOverride, &p.LatestOverride,
|
||||
&p.SortOrder, &p.CreatedAt, &p.UpdatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, p)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// dashboardTenantMatches is the join every panel-mutating method below
|
||||
// uses in place of a tenant_id column dashboard_panels doesn't have --
|
||||
// "does this dashboard exist AND belong to this tenant." A plain
|
||||
// EXISTS query, not a full row fetch: the panel methods that call this
|
||||
// only need a yes/no gate, not the dashboard's data.
|
||||
func (s *Store) dashboardTenantMatches(ctx context.Context, tenantID, dashboardID string) (bool, error) {
|
||||
var exists bool
|
||||
err := s.pool.QueryRow(ctx,
|
||||
`SELECT EXISTS(SELECT 1 FROM dashboards WHERE id = $1 AND tenant_id = $2)`,
|
||||
dashboardID, tenantID,
|
||||
).Scan(&exists)
|
||||
return exists, err
|
||||
}
|
||||
|
||||
func (s *Store) UpdateDashboard(ctx context.Context, tenantID string, d *Dashboard) error {
|
||||
if d.DefaultEarliest == "" {
|
||||
d.DefaultEarliest = "-1h"
|
||||
}
|
||||
if d.DefaultLatest == "" {
|
||||
d.DefaultLatest = "now"
|
||||
}
|
||||
row := s.pool.QueryRow(ctx, `
|
||||
UPDATE dashboards SET name = $1, description = $2, default_earliest = $3, default_latest = $4, updated_at = now()
|
||||
WHERE id = $5 AND tenant_id = $6
|
||||
RETURNING tenant_id, created_by, created_at, updated_at`,
|
||||
d.Name, d.Description, d.DefaultEarliest, d.DefaultLatest, d.ID, tenantID)
|
||||
if err := row.Scan(&d.TenantID, &d.CreatedBy, &d.CreatedAt, &d.UpdatedAt); err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return ErrNotFound
|
||||
}
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) DeleteDashboard(ctx context.Context, tenantID, id string) error {
|
||||
tag, err := s.pool.Exec(ctx, `DELETE FROM dashboards WHERE id = $1 AND tenant_id = $2`, id, tenantID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) AddPanel(ctx context.Context, tenantID, dashboardID string, p *Panel) error {
|
||||
ok, err := s.dashboardTenantMatches(ctx, tenantID, dashboardID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !ok {
|
||||
return ErrNotFound
|
||||
}
|
||||
p.ID = uuid.NewString()
|
||||
p.DashboardID = dashboardID
|
||||
row := s.pool.QueryRow(ctx, `
|
||||
INSERT INTO dashboard_panels (id, dashboard_id, title, query, query_language, viz_type, viz_config,
|
||||
position_x, position_y, width, height, earliest_override, latest_override, sort_order)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)
|
||||
RETURNING created_at, updated_at`,
|
||||
p.ID, p.DashboardID, p.Title, p.Query, p.QueryLanguage, p.VizType, p.VizConfig,
|
||||
p.PositionX, p.PositionY, p.Width, p.Height, p.EarliestOverride, p.LatestOverride, p.SortOrder)
|
||||
return row.Scan(&p.CreatedAt, &p.UpdatedAt)
|
||||
}
|
||||
|
||||
func (s *Store) UpdatePanel(ctx context.Context, tenantID string, p *Panel) error {
|
||||
ok, err := s.dashboardTenantMatches(ctx, tenantID, p.DashboardID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !ok {
|
||||
return ErrNotFound
|
||||
}
|
||||
tag, err := s.pool.Exec(ctx, `
|
||||
UPDATE dashboard_panels SET
|
||||
title = $1, query = $2, query_language = $3, viz_type = $4, viz_config = $5,
|
||||
position_x = $6, position_y = $7, width = $8, height = $9,
|
||||
earliest_override = $10, latest_override = $11, sort_order = $12, updated_at = now()
|
||||
WHERE id = $13 AND dashboard_id = $14`,
|
||||
p.Title, p.Query, p.QueryLanguage, p.VizType, p.VizConfig,
|
||||
p.PositionX, p.PositionY, p.Width, p.Height,
|
||||
p.EarliestOverride, p.LatestOverride, p.SortOrder, p.ID, p.DashboardID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) DeletePanel(ctx context.Context, tenantID, dashboardID, panelID string) error {
|
||||
ok, err := s.dashboardTenantMatches(ctx, tenantID, dashboardID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !ok {
|
||||
return ErrNotFound
|
||||
}
|
||||
tag, err := s.pool.Exec(ctx, `DELETE FROM dashboard_panels WHERE id = $1 AND dashboard_id = $2`, panelID, dashboardID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ImportDashboard creates a new dashboard and all its panels from an
|
||||
// exported Dashboard document, assigning fresh IDs throughout -- so
|
||||
// importing an exported dashboard into a different environment (or
|
||||
// re-importing into the same one) never collides with the source IDs.
|
||||
// Runs in one transaction: either the whole dashboard lands, or none of
|
||||
// it does. tenantID comes from the caller (the authenticated identity),
|
||||
// never from d.TenantID -- an exported dashboard JSON file carries
|
||||
// whatever tenant_id it was exported from, and importing it must not
|
||||
// let that value silently re-assign the dashboard to a different
|
||||
// tenant than the importing user's own.
|
||||
func (s *Store) ImportDashboard(ctx context.Context, tenantID string, d *Dashboard) (*Dashboard, error) {
|
||||
tx, err := s.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
id := uuid.NewString()
|
||||
createdBy := d.CreatedBy
|
||||
if createdBy == "" {
|
||||
createdBy = "anonymous"
|
||||
}
|
||||
earliest := d.DefaultEarliest
|
||||
if earliest == "" {
|
||||
earliest = "-1h"
|
||||
}
|
||||
latest := d.DefaultLatest
|
||||
if latest == "" {
|
||||
latest = "now"
|
||||
}
|
||||
|
||||
var out Dashboard
|
||||
out.ID, out.TenantID, out.Name, out.Description = id, tenantID, d.Name, d.Description
|
||||
out.DefaultEarliest, out.DefaultLatest, out.CreatedBy = earliest, latest, createdBy
|
||||
|
||||
row := tx.QueryRow(ctx, `
|
||||
INSERT INTO dashboards (id, tenant_id, name, description, default_earliest, default_latest, created_by)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
RETURNING created_at, updated_at`,
|
||||
out.ID, out.TenantID, out.Name, out.Description, out.DefaultEarliest, out.DefaultLatest, out.CreatedBy)
|
||||
if err := row.Scan(&out.CreatedAt, &out.UpdatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, p := range d.Panels {
|
||||
if err := validatePanel(&p); err != nil {
|
||||
return nil, fmt.Errorf("panel %q: %w", p.Title, err)
|
||||
}
|
||||
p.ID = uuid.NewString()
|
||||
p.DashboardID = out.ID
|
||||
prow := tx.QueryRow(ctx, `
|
||||
INSERT INTO dashboard_panels (id, dashboard_id, title, query, query_language, viz_type, viz_config,
|
||||
position_x, position_y, width, height, earliest_override, latest_override, sort_order)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)
|
||||
RETURNING created_at, updated_at`,
|
||||
p.ID, p.DashboardID, p.Title, p.Query, p.QueryLanguage, p.VizType, p.VizConfig,
|
||||
p.PositionX, p.PositionY, p.Width, p.Height, p.EarliestOverride, p.LatestOverride, p.SortOrder)
|
||||
if err := prow.Scan(&p.CreatedAt, &p.UpdatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out.Panels = append(out.Panels, p)
|
||||
}
|
||||
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &out, nil
|
||||
}
|
||||
@@ -1,192 +0,0 @@
|
||||
// Adversarial cross-tenant isolation tests against a real Postgres --
|
||||
// Phase 4 task 8. handler_test.go's TestCrossTenant* tests already cover
|
||||
// this against fakeStore (which is hand-written to mimic the real SQL's
|
||||
// tenant filtering); these tests exercise the actual parameterized SQL
|
||||
// in store.go, including the tenant_id foreign key constraint added in
|
||||
// metadata/migrations/0027_add_dashboards_tenant_fk.sql -- a real gap a
|
||||
// fake store literally cannot catch (e.g. a typo in a WHERE clause, or
|
||||
// forgetting to update every method when the schema changes).
|
||||
//
|
||||
// Skipped unless DASHBOARDS_TEST_POSTGRES_ADDR is set; run via:
|
||||
//
|
||||
// docker run --rm --network sentry_default -v $(pwd)/../../..:/src -w /src/api \
|
||||
// -e DASHBOARDS_TEST_POSTGRES_ADDR=metadata-postgres:5432 \
|
||||
// -e DASHBOARDS_TEST_POSTGRES_PASSWORD=sentry-dev-only \
|
||||
// golang:1.25-alpine go test ./internal/dashboards/... -run Integration -v
|
||||
package dashboards
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
func integrationStore(t *testing.T) (*Store, *pgxpool.Pool) {
|
||||
t.Helper()
|
||||
addr := os.Getenv("DASHBOARDS_TEST_POSTGRES_ADDR")
|
||||
if addr == "" {
|
||||
t.Skip("DASHBOARDS_TEST_POSTGRES_ADDR not set -- skipping live-Postgres integration test")
|
||||
}
|
||||
password := os.Getenv("DASHBOARDS_TEST_POSTGRES_PASSWORD")
|
||||
dsn := fmt.Sprintf("postgres://sentry:%s@%s/sentry_metadata", password, addr)
|
||||
pool, err := pgxpool.New(context.Background(), dsn)
|
||||
if err != nil {
|
||||
t.Fatalf("opening pool: %v", err)
|
||||
}
|
||||
t.Cleanup(pool.Close)
|
||||
return NewStore(pool), pool
|
||||
}
|
||||
|
||||
// createTestTenant inserts directly into the tenants table (owned by
|
||||
// metadata/migrations/0017-0019, not this package) -- dashboards.tenant_id
|
||||
// has had a real foreign-key constraint since
|
||||
// metadata/migrations/0027_add_dashboards_tenant_fk.sql, so a dashboard
|
||||
// row for a tenant that doesn't exist in `tenants` is rejected by
|
||||
// Postgres itself, not just application logic. Uses a unique suffix so
|
||||
// repeated test runs against a persistent dev Postgres don't collide.
|
||||
func createTestTenant(t *testing.T, pool *pgxpool.Pool) string {
|
||||
t.Helper()
|
||||
id := "test-tenant-" + uuid.NewString()[:8]
|
||||
_, err := pool.Exec(context.Background(),
|
||||
`INSERT INTO tenants (id, display_name, status) VALUES ($1, $1, 'active')`, id)
|
||||
if err != nil {
|
||||
t.Fatalf("creating test tenant: %v", err)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
func TestIntegrationCrossTenantGetIsNotFound(t *testing.T) {
|
||||
store, pool := integrationStore(t)
|
||||
ctx := context.Background()
|
||||
tenantA := createTestTenant(t, pool)
|
||||
tenantB := createTestTenant(t, pool)
|
||||
|
||||
d := &Dashboard{TenantID: tenantA, Name: "Acme's dashboard"}
|
||||
if err := store.CreateDashboard(ctx, d); err != nil {
|
||||
t.Fatalf("CreateDashboard: %v", err)
|
||||
}
|
||||
|
||||
// Same tenant: found.
|
||||
if _, err := store.GetDashboard(ctx, tenantA, d.ID); err != nil {
|
||||
t.Fatalf("GetDashboard (same tenant): %v", err)
|
||||
}
|
||||
// Different tenant: not found, not a data leak.
|
||||
if _, err := store.GetDashboard(ctx, tenantB, d.ID); err != ErrNotFound {
|
||||
t.Fatalf("GetDashboard (cross-tenant) error = %v, want ErrNotFound", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntegrationCrossTenantListDoesNotLeak(t *testing.T) {
|
||||
store, pool := integrationStore(t)
|
||||
ctx := context.Background()
|
||||
tenantA := createTestTenant(t, pool)
|
||||
tenantB := createTestTenant(t, pool)
|
||||
|
||||
da := &Dashboard{TenantID: tenantA, Name: "A's dashboard"}
|
||||
db := &Dashboard{TenantID: tenantB, Name: "B's dashboard"}
|
||||
if err := store.CreateDashboard(ctx, da); err != nil {
|
||||
t.Fatalf("CreateDashboard A: %v", err)
|
||||
}
|
||||
if err := store.CreateDashboard(ctx, db); err != nil {
|
||||
t.Fatalf("CreateDashboard B: %v", err)
|
||||
}
|
||||
|
||||
listA, err := store.ListDashboards(ctx, tenantA)
|
||||
if err != nil {
|
||||
t.Fatalf("ListDashboards A: %v", err)
|
||||
}
|
||||
for _, d := range listA {
|
||||
if d.TenantID != tenantA {
|
||||
t.Fatalf("tenant A's list leaked a dashboard from tenant %q", d.TenantID)
|
||||
}
|
||||
}
|
||||
found := false
|
||||
for _, d := range listA {
|
||||
if d.ID == da.ID {
|
||||
found = true
|
||||
}
|
||||
if d.ID == db.ID {
|
||||
t.Fatalf("tenant A's list included tenant B's dashboard %q", db.ID)
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("tenant A's list did not include tenant A's own dashboard")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntegrationCrossTenantUpdateAndDeleteAreNotFound(t *testing.T) {
|
||||
store, pool := integrationStore(t)
|
||||
ctx := context.Background()
|
||||
tenantA := createTestTenant(t, pool)
|
||||
tenantB := createTestTenant(t, pool)
|
||||
|
||||
d := &Dashboard{TenantID: tenantA, Name: "Original"}
|
||||
if err := store.CreateDashboard(ctx, d); err != nil {
|
||||
t.Fatalf("CreateDashboard: %v", err)
|
||||
}
|
||||
|
||||
hijack := &Dashboard{ID: d.ID, Name: "Hijacked"}
|
||||
if err := store.UpdateDashboard(ctx, tenantB, hijack); err != ErrNotFound {
|
||||
t.Fatalf("cross-tenant UpdateDashboard error = %v, want ErrNotFound", err)
|
||||
}
|
||||
got, err := store.GetDashboard(ctx, tenantA, d.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetDashboard after attempted cross-tenant update: %v", err)
|
||||
}
|
||||
if got.Name != "Original" {
|
||||
t.Fatalf("cross-tenant update mutated the row: Name = %q", got.Name)
|
||||
}
|
||||
|
||||
if err := store.DeleteDashboard(ctx, tenantB, d.ID); err != ErrNotFound {
|
||||
t.Fatalf("cross-tenant DeleteDashboard error = %v, want ErrNotFound", err)
|
||||
}
|
||||
if _, err := store.GetDashboard(ctx, tenantA, d.ID); err != nil {
|
||||
t.Fatalf("expected the dashboard to still exist after a failed cross-tenant delete: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntegrationCrossTenantPanelMutationIsNotFound(t *testing.T) {
|
||||
store, pool := integrationStore(t)
|
||||
ctx := context.Background()
|
||||
tenantA := createTestTenant(t, pool)
|
||||
tenantB := createTestTenant(t, pool)
|
||||
|
||||
d := &Dashboard{TenantID: tenantA, Name: "Has panels"}
|
||||
if err := store.CreateDashboard(ctx, d); err != nil {
|
||||
t.Fatalf("CreateDashboard: %v", err)
|
||||
}
|
||||
|
||||
p := &Panel{Query: "service=api", VizType: VizTable, Width: 6, Height: 4}
|
||||
if err := store.AddPanel(ctx, tenantB, d.ID, p); err != ErrNotFound {
|
||||
t.Fatalf("cross-tenant AddPanel error = %v, want ErrNotFound", err)
|
||||
}
|
||||
|
||||
// Add it for real (tenant A), then confirm tenant B can't update/delete it either.
|
||||
if err := store.AddPanel(ctx, tenantA, d.ID, p); err != nil {
|
||||
t.Fatalf("AddPanel (same tenant): %v", err)
|
||||
}
|
||||
p.Title = "Hijacked"
|
||||
if err := store.UpdatePanel(ctx, tenantB, p); err != ErrNotFound {
|
||||
t.Fatalf("cross-tenant UpdatePanel error = %v, want ErrNotFound", err)
|
||||
}
|
||||
if err := store.DeletePanel(ctx, tenantB, d.ID, p.ID); err != ErrNotFound {
|
||||
t.Fatalf("cross-tenant DeletePanel error = %v, want ErrNotFound", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestIntegrationDashboardTenantForeignKeyRejectsUnknownTenant proves
|
||||
// the database itself, not just application code, refuses a dashboard
|
||||
// for a tenant that was never provisioned -- defense in depth
|
||||
// independent of the Go-level tenant scoping above (see
|
||||
// metadata/migrations/0027_add_dashboards_tenant_fk.sql).
|
||||
func TestIntegrationDashboardTenantForeignKeyRejectsUnknownTenant(t *testing.T) {
|
||||
store, _ := integrationStore(t)
|
||||
d := &Dashboard{TenantID: "does-not-exist-" + uuid.NewString()[:8], Name: "Orphan"}
|
||||
if err := store.CreateDashboard(context.Background(), d); err == nil {
|
||||
t.Fatal("expected CreateDashboard to fail for a tenant_id with no matching tenants row")
|
||||
}
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
// Package dashboards implements CRUD for saved, multi-panel dashboards
|
||||
// -- see /docs/phase-3-dashboard-design.md. Deliberately pure CRUD: panel
|
||||
// *query execution* happens client-side (the web UI calls the existing
|
||||
// POST /query per panel), so this package never touches querylang.
|
||||
package dashboards
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// VizType is one of the panel visualization kinds. "top_n" renders
|
||||
// through the same path as "table" -- the query itself already did the
|
||||
// sort/limit -- so there's no execution-side difference, only UI framing.
|
||||
type VizType string
|
||||
|
||||
const (
|
||||
VizTable VizType = "table"
|
||||
VizLine VizType = "line"
|
||||
VizBar VizType = "bar"
|
||||
VizSingleStat VizType = "single_stat"
|
||||
VizTopN VizType = "top_n"
|
||||
)
|
||||
|
||||
func validVizType(v VizType) bool {
|
||||
switch v {
|
||||
case VizTable, VizLine, VizBar, VizSingleStat, VizTopN:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
type Dashboard struct {
|
||||
ID string `json:"id"`
|
||||
TenantID string `json:"tenant_id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
DefaultEarliest string `json:"default_earliest"`
|
||||
DefaultLatest string `json:"default_latest"`
|
||||
CreatedBy string `json:"created_by"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
Panels []Panel `json:"panels,omitempty"`
|
||||
}
|
||||
|
||||
type Panel struct {
|
||||
ID string `json:"id"`
|
||||
DashboardID string `json:"dashboard_id"`
|
||||
Title string `json:"title"`
|
||||
Query string `json:"query"`
|
||||
QueryLanguage string `json:"query_language"`
|
||||
VizType VizType `json:"viz_type"`
|
||||
VizConfig json.RawMessage `json:"viz_config,omitempty"`
|
||||
PositionX int `json:"position_x"`
|
||||
PositionY int `json:"position_y"`
|
||||
Width int `json:"width"`
|
||||
Height int `json:"height"`
|
||||
EarliestOverride *string `json:"earliest_override,omitempty"`
|
||||
LatestOverride *string `json:"latest_override,omitempty"`
|
||||
SortOrder int `json:"sort_order"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// validatePanel enforces the two rules /docs/phase-3-dashboard-design.md
|
||||
// states as disclosed non-goals rather than silent gaps: raw-SQL panels
|
||||
// aren't supported (time-range injection has no reliable splice point
|
||||
// into arbitrary SQL), and viz_type must be one this API knows how to
|
||||
// store/render.
|
||||
func validatePanel(p *Panel) error {
|
||||
if p.Query == "" {
|
||||
return fmt.Errorf("query must not be empty")
|
||||
}
|
||||
if p.QueryLanguage == "sql" {
|
||||
return fmt.Errorf("raw-SQL panels are not supported -- dashboards only support pipe-syntax queries, since the dashboard time-range picker is injected as leading query terms")
|
||||
}
|
||||
if !validVizType(p.VizType) {
|
||||
return fmt.Errorf("viz_type must be one of table, line, bar, single_stat, top_n, got %q", p.VizType)
|
||||
}
|
||||
if len(p.VizConfig) == 0 {
|
||||
p.VizConfig = json.RawMessage(`{}`)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
// Package httpserver holds cross-handler HTTP concerns for /api. Phase 3
|
||||
// introduced a second handler package (internal/dashboards) alongside
|
||||
// internal/queryapi, so CORS moved out of individual handlers into one
|
||||
// wrap applied around the fully-assembled mux in cmd/api/main.go, rather
|
||||
// than each handler package wrapping itself.
|
||||
package httpserver
|
||||
|
||||
import "net/http"
|
||||
|
||||
// WithCORS is deliberately permissive by default (see CORSAllowedOrigin
|
||||
// in internal/config) since there's no auth yet and the SvelteKit dev
|
||||
// server runs on a different origin. Tighten alongside adding real auth.
|
||||
func WithCORS(next http.Handler, allowedOrigin string) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Access-Control-Allow-Origin", allowedOrigin)
|
||||
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
|
||||
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
|
||||
if r.Method == http.MethodOptions {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
package httpserver
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestWithCORSPreflight(t *testing.T) {
|
||||
inner := http.NewServeMux()
|
||||
inner.HandleFunc("POST /query", func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
h := WithCORS(inner, "*")
|
||||
|
||||
req := httptest.NewRequest(http.MethodOptions, "/query", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusNoContent {
|
||||
t.Fatalf("status = %d, want 204", rec.Code)
|
||||
}
|
||||
if got := rec.Header().Get("Access-Control-Allow-Origin"); got != "*" {
|
||||
t.Fatalf("Access-Control-Allow-Origin = %q, want *", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWithCORSPassesThroughNonPreflight(t *testing.T) {
|
||||
inner := http.NewServeMux()
|
||||
inner.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
h := WithCORS(inner, "*")
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/healthz", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", rec.Code)
|
||||
}
|
||||
}
|
||||
@@ -1,186 +0,0 @@
|
||||
// Package queryapi is Sentry's query API: a single POST /query endpoint
|
||||
// accepting either the pipe syntax or raw SQL, compiled by
|
||||
// querylang/planner and executed by querylang/executor. Replaces Phase
|
||||
// 0/1's two separate placeholder endpoints (raw-SQL-only /query,
|
||||
// free-text-only /search) -- see /docs/query-language-design.md.
|
||||
//
|
||||
// Still plain net/http, not the pinned gRPC+REST-gateway pattern, for
|
||||
// the same reason as Phase 0/1: this is one endpoint, and the
|
||||
// proto/annotations/codegen machinery doesn't buy much at that size.
|
||||
// `/api` does speak gRPC internally (to /search) — this simplification
|
||||
// is about the public-facing surface only.
|
||||
package queryapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/sentry/sentry/api/internal/authz"
|
||||
"github.com/sentry/sentry/api/internal/querylang/executor"
|
||||
"github.com/sentry/sentry/api/internal/querylang/planner"
|
||||
)
|
||||
|
||||
// AuditLogger is core's extension point for query audit logging --
|
||||
// deliberately minimal and tenant-agnostic, since core has no concept of
|
||||
// tenants (see /docs/phase-4-isolation-design.md: that mechanism lives
|
||||
// entirely in enterprise/). enterprise/internal/audit implements this
|
||||
// against the real hash-chained, append-only store; a nil AuditLogger
|
||||
// (the default for a single-tenant deployment without enterprise/
|
||||
// configured) means no audit logging happens and core behaves exactly
|
||||
// as it did in Phases 0-3.
|
||||
//
|
||||
// Tenant/user identity is deliberately NOT a field on QueryAuditEntry --
|
||||
// once Phase 4 task 5's auth middleware wraps this handler, it attaches
|
||||
// that identity to the request's context.Context via
|
||||
// enterprise/internal/tenant, and LogQuery's ctx parameter is the same
|
||||
// context the request carried, so an enterprise-side implementation
|
||||
// reads identity from ctx rather than this interface growing
|
||||
// tenant-awareness. Per /docs/phase-4-isolation-design.md's audit
|
||||
// section, this is a fail-open path: a LogQuery error is logged but
|
||||
// never fails the HTTP response for a routine read query.
|
||||
type AuditLogger interface {
|
||||
LogQuery(ctx context.Context, entry QueryAuditEntry) error
|
||||
}
|
||||
|
||||
type QueryAuditEntry struct {
|
||||
Query string
|
||||
Language string
|
||||
RowCount int
|
||||
Duration time.Duration
|
||||
Success bool
|
||||
Error string
|
||||
}
|
||||
|
||||
type Handler struct {
|
||||
logger *slog.Logger
|
||||
sqlRunner executor.SQLRunner
|
||||
search executor.SearchClient
|
||||
queryTimeout time.Duration
|
||||
audit AuditLogger
|
||||
authorizer authz.Authorizer
|
||||
}
|
||||
|
||||
// audit and authorizer may both be nil -- see AuditLogger's doc comment
|
||||
// and authz.RequireRoleOrService's nil-safety. /query allows RoleViewer
|
||||
// (human sessions) or the alerting service identity (RoleService) --
|
||||
// it's the one endpoint /alerting's evaluator legitimately calls, per
|
||||
// /docs/phase-4-isolation-design.md's alerting service-identity design.
|
||||
func NewHandler(logger *slog.Logger, sqlRunner executor.SQLRunner, search executor.SearchClient, queryTimeout time.Duration, audit AuditLogger, authorizer authz.Authorizer) *Handler {
|
||||
return &Handler{logger: logger, sqlRunner: sqlRunner, search: search, queryTimeout: queryTimeout, audit: audit, authorizer: authorizer}
|
||||
}
|
||||
|
||||
// RegisterRoutes adds this handler's routes onto a shared mux. Phase 3
|
||||
// introduced a second handler package (internal/dashboards), so CORS is
|
||||
// now applied once, by main.go, around the fully-assembled mux rather
|
||||
// than by each handler wrapping itself individually -- see
|
||||
// httpserver.WithCORS.
|
||||
func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
|
||||
mux.HandleFunc("POST /query", authz.RequireRoleOrService(h.authorizer, authz.RoleViewer, h.handleQuery))
|
||||
mux.HandleFunc("GET /healthz", h.handleHealthz)
|
||||
}
|
||||
|
||||
func (h *Handler) handleHealthz(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
type queryRequest struct {
|
||||
Query string `json:"query"`
|
||||
// Language overrides auto-detection ("" / omitted). "sql" or "spl" --
|
||||
// see planner.Language and /docs/query-language-design.md's
|
||||
// "Detection" section for why this exists: the rare case a pipe
|
||||
// query legitimately starts with the literal word "select".
|
||||
Language string `json:"language"`
|
||||
}
|
||||
|
||||
type queryResponse struct {
|
||||
Columns []string `json:"columns"`
|
||||
Rows [][]any `json:"rows"`
|
||||
}
|
||||
|
||||
type errorResponse struct {
|
||||
Error string `json:"error"`
|
||||
}
|
||||
|
||||
// maxBodyBytes caps the request body: a query string has no legitimate
|
||||
// reason to be larger than this.
|
||||
const maxBodyBytes = 1 << 20 // 1 MiB
|
||||
|
||||
func (h *Handler) handleQuery(w http.ResponseWriter, r *http.Request) {
|
||||
r.Body = http.MaxBytesReader(w, r.Body, maxBodyBytes)
|
||||
|
||||
var req queryRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid JSON body: "+err.Error())
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(req.Query) == "" {
|
||||
writeError(w, http.StatusBadRequest, "query must not be empty")
|
||||
return
|
||||
}
|
||||
|
||||
lang := planner.Language(req.Language)
|
||||
if lang != planner.Auto && lang != planner.SQL && lang != planner.SPL {
|
||||
writeError(w, http.StatusBadRequest, `language must be "sql", "spl", or omitted`)
|
||||
return
|
||||
}
|
||||
|
||||
plan, err := planner.Compile(req.Query, lang, time.Now())
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(r.Context(), h.queryTimeout)
|
||||
defer cancel()
|
||||
|
||||
start := time.Now()
|
||||
result, err := executor.Execute(ctx, plan, h.sqlRunner, h.search)
|
||||
duration := time.Since(start)
|
||||
|
||||
if err != nil {
|
||||
h.logger.Error("query execution failed", "query", req.Query, "error", err)
|
||||
h.logAudit(r.Context(), req, 0, duration, err)
|
||||
writeError(w, http.StatusBadGateway, "query failed: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
h.logAudit(r.Context(), req, len(result.Rows), duration, nil)
|
||||
writeJSON(w, queryResponse{Columns: result.Columns, Rows: result.Rows})
|
||||
}
|
||||
|
||||
// logAudit is fail-open by design (see AuditLogger's doc comment): a
|
||||
// write failure here is logged and otherwise ignored, never surfaced to
|
||||
// the HTTP caller. Uses r.Context() (the original request context, not
|
||||
// the query-execution one with its own deadline) so a slow/cancelled
|
||||
// query's context.WithTimeout expiring doesn't also cancel the audit
|
||||
// write for it.
|
||||
func (h *Handler) logAudit(ctx context.Context, req queryRequest, rowCount int, duration time.Duration, execErr error) {
|
||||
if h.audit == nil {
|
||||
return
|
||||
}
|
||||
entry := QueryAuditEntry{
|
||||
Query: req.Query, Language: req.Language, RowCount: rowCount,
|
||||
Duration: duration, Success: execErr == nil,
|
||||
}
|
||||
if execErr != nil {
|
||||
entry.Error = execErr.Error()
|
||||
}
|
||||
if err := h.audit.LogQuery(ctx, entry); err != nil {
|
||||
h.logger.Error("audit log write failed", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
|
||||
func writeError(w http.ResponseWriter, status int, msg string) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(errorResponse{Error: msg})
|
||||
}
|
||||
@@ -1,342 +0,0 @@
|
||||
package queryapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/sentry/sentry/api/internal/authz"
|
||||
"github.com/sentry/sentry/api/internal/querylang/executor"
|
||||
)
|
||||
|
||||
type fakeSQLRunner struct {
|
||||
result *executor.Result
|
||||
err error
|
||||
gotSQL string
|
||||
}
|
||||
|
||||
func (f *fakeSQLRunner) RunSQL(_ context.Context, sql string) (*executor.Result, error) {
|
||||
f.gotSQL = sql
|
||||
if f.err != nil {
|
||||
return nil, f.err
|
||||
}
|
||||
if f.result != nil {
|
||||
return f.result, nil
|
||||
}
|
||||
return &executor.Result{Columns: []string{}, Rows: [][]any{}}, nil
|
||||
}
|
||||
|
||||
type fakeSearchClient struct {
|
||||
recordIDs []string
|
||||
err error
|
||||
gotQuery string
|
||||
}
|
||||
|
||||
func (f *fakeSearchClient) Search(_ context.Context, query string, _ uint32) ([]string, error) {
|
||||
f.gotQuery = query
|
||||
if f.err != nil {
|
||||
return nil, f.err
|
||||
}
|
||||
return f.recordIDs, nil
|
||||
}
|
||||
|
||||
func newTestHandler(sqlRunner *fakeSQLRunner, search *fakeSearchClient) *Handler {
|
||||
if search == nil {
|
||||
search = &fakeSearchClient{}
|
||||
}
|
||||
return NewHandler(slog.New(slog.NewTextHandler(io.Discard, nil)), sqlRunner, search, time.Second, nil, nil)
|
||||
}
|
||||
|
||||
type fakeAuditLogger struct {
|
||||
entries []QueryAuditEntry
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *fakeAuditLogger) LogQuery(_ context.Context, entry QueryAuditEntry) error {
|
||||
f.entries = append(f.entries, entry)
|
||||
return f.err
|
||||
}
|
||||
|
||||
func newTestHandlerWithAudit(sqlRunner *fakeSQLRunner, audit *fakeAuditLogger) *Handler {
|
||||
return NewHandler(slog.New(slog.NewTextHandler(io.Discard, nil)), sqlRunner, &fakeSearchClient{}, time.Second, audit, nil)
|
||||
}
|
||||
|
||||
func newTestMux(h *Handler) *http.ServeMux {
|
||||
mux := http.NewServeMux()
|
||||
h.RegisterRoutes(mux)
|
||||
return mux
|
||||
}
|
||||
|
||||
func postQuery(t *testing.T, h *Handler, body string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
req := httptest.NewRequest(http.MethodPost, "/query", strings.NewReader(body))
|
||||
rec := httptest.NewRecorder()
|
||||
newTestMux(h).ServeHTTP(rec, req)
|
||||
return rec
|
||||
}
|
||||
|
||||
func TestHandleQuerySQLSuccess(t *testing.T) {
|
||||
sr := &fakeSQLRunner{result: &executor.Result{
|
||||
Columns: []string{"host", "count"},
|
||||
Rows: [][]any{{"h1", 3}},
|
||||
}}
|
||||
h := newTestHandler(sr, nil)
|
||||
|
||||
rec := postQuery(t, h, `{"query": "SELECT host, count(*) FROM logs GROUP BY host"}`)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var got queryResponse
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
|
||||
t.Fatalf("decoding response: %v", err)
|
||||
}
|
||||
if len(got.Columns) != 2 || len(got.Rows) != 1 {
|
||||
t.Fatalf("unexpected result: %+v", got)
|
||||
}
|
||||
if sr.gotSQL != "SELECT host, count(*) FROM logs GROUP BY host" {
|
||||
t.Fatalf("unexpected SQL passed through: %q", sr.gotSQL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleQueryPipeSyntaxSuccess(t *testing.T) {
|
||||
sr := &fakeSQLRunner{result: &executor.Result{
|
||||
Columns: []string{"host"},
|
||||
Rows: [][]any{{"api"}},
|
||||
}}
|
||||
h := newTestHandler(sr, nil)
|
||||
|
||||
rec := postQuery(t, h, `{"query": "service=api"}`)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if !strings.Contains(sr.gotSQL, "`service` = 'api'") {
|
||||
t.Fatalf("expected compiled SQL to filter on service, got: %s", sr.gotSQL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleQueryTextSearchRoutesThroughSearchClient(t *testing.T) {
|
||||
sr := &fakeSQLRunner{}
|
||||
fs := &fakeSearchClient{recordIDs: []string{"id-1"}}
|
||||
h := newTestHandler(sr, fs)
|
||||
|
||||
rec := postQuery(t, h, `{"query": "message:\"connection refused\""}`)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if fs.gotQuery != `"connection refused"` {
|
||||
t.Fatalf("search query = %q", fs.gotQuery)
|
||||
}
|
||||
if !strings.Contains(sr.gotSQL, "record_id IN ('id-1')") {
|
||||
t.Fatalf("expected the search prefilter in the generated SQL, got: %s", sr.gotSQL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleQueryRejectsEmptyQuery(t *testing.T) {
|
||||
h := newTestHandler(&fakeSQLRunner{}, nil)
|
||||
rec := postQuery(t, h, `{"query": " "}`)
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want 400", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleQueryRejectsInvalidJSON(t *testing.T) {
|
||||
h := newTestHandler(&fakeSQLRunner{}, nil)
|
||||
rec := postQuery(t, h, `not json`)
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want 400", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleQueryRejectsCompileError(t *testing.T) {
|
||||
h := newTestHandler(&fakeSQLRunner{}, nil)
|
||||
rec := postQuery(t, h, `{"query": "service=api | bogus"}`)
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want 400; body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleQueryRejectsNonSelectSQL(t *testing.T) {
|
||||
h := newTestHandler(&fakeSQLRunner{}, nil)
|
||||
rec := postQuery(t, h, `{"query": "DELETE FROM logs", "language": "sql"}`)
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want 400", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleQueryRejectsInvalidLanguage(t *testing.T) {
|
||||
h := newTestHandler(&fakeSQLRunner{}, nil)
|
||||
rec := postQuery(t, h, `{"query": "service=api", "language": "cobol"}`)
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want 400", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleQueryExplicitLanguageOverridesAutoDetect(t *testing.T) {
|
||||
sr := &fakeSQLRunner{}
|
||||
fs := &fakeSearchClient{recordIDs: []string{"id-1"}}
|
||||
h := newTestHandler(sr, fs)
|
||||
|
||||
// "select" alone would auto-detect as (nonsensical but
|
||||
// syntactically-valid-looking) SQL without the override -- the
|
||||
// override forces pipe-syntax parsing instead, where a bare word
|
||||
// with no comparator is a free-text search term.
|
||||
rec := postQuery(t, h, `{"query": "select", "language": "spl"}`)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if fs.gotQuery != "select" {
|
||||
t.Fatalf("expected 'select' to be treated as a free-text search term, got query=%q", fs.gotQuery)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleQueryExecutorErrorReturnsBadGateway(t *testing.T) {
|
||||
sr := &fakeSQLRunner{err: errors.New("boom")}
|
||||
h := newTestHandler(sr, nil)
|
||||
|
||||
rec := postQuery(t, h, `{"query": "SELECT 1"}`)
|
||||
|
||||
if rec.Code != http.StatusBadGateway {
|
||||
t.Fatalf("status = %d, want 502", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleHealthz(t *testing.T) {
|
||||
h := newTestHandler(&fakeSQLRunner{}, nil)
|
||||
req := httptest.NewRequest(http.MethodGet, "/healthz", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
newTestMux(h).ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleQueryLogsAuditEntryOnSuccess(t *testing.T) {
|
||||
sr := &fakeSQLRunner{result: &executor.Result{Columns: []string{"host"}, Rows: [][]any{{"h1"}, {"h2"}}}}
|
||||
audit := &fakeAuditLogger{}
|
||||
h := newTestHandlerWithAudit(sr, audit)
|
||||
|
||||
rec := postQuery(t, h, `{"query": "SELECT host FROM logs"}`)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
if len(audit.entries) != 1 {
|
||||
t.Fatalf("expected 1 audit entry, got %d", len(audit.entries))
|
||||
}
|
||||
entry := audit.entries[0]
|
||||
if entry.Query != "SELECT host FROM logs" || !entry.Success || entry.RowCount != 2 || entry.Error != "" {
|
||||
t.Fatalf("unexpected audit entry: %+v", entry)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleQueryLogsAuditEntryOnFailure(t *testing.T) {
|
||||
sr := &fakeSQLRunner{err: errors.New("boom")}
|
||||
audit := &fakeAuditLogger{}
|
||||
h := newTestHandlerWithAudit(sr, audit)
|
||||
|
||||
rec := postQuery(t, h, `{"query": "SELECT 1"}`)
|
||||
if rec.Code != http.StatusBadGateway {
|
||||
t.Fatalf("status = %d, want 502", rec.Code)
|
||||
}
|
||||
|
||||
if len(audit.entries) != 1 {
|
||||
t.Fatalf("expected 1 audit entry even on failure, got %d", len(audit.entries))
|
||||
}
|
||||
entry := audit.entries[0]
|
||||
if entry.Success || entry.Error == "" {
|
||||
t.Fatalf("expected a failed audit entry with an error message, got %+v", entry)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandleQueryAuditWriteFailureDoesNotFailRequest proves the
|
||||
// fail-open design: a request still succeeds even when the audit
|
||||
// logger itself errors -- per queryapi.AuditLogger's doc comment and
|
||||
// /docs/phase-4-isolation-design.md's audit fail-open/fail-closed policy.
|
||||
func TestHandleQueryAuditWriteFailureDoesNotFailRequest(t *testing.T) {
|
||||
sr := &fakeSQLRunner{result: &executor.Result{Columns: []string{}, Rows: [][]any{}}}
|
||||
audit := &fakeAuditLogger{err: errors.New("audit backend unreachable")}
|
||||
h := newTestHandlerWithAudit(sr, audit)
|
||||
|
||||
rec := postQuery(t, h, `{"query": "SELECT 1"}`)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200 -- an audit write failure must not fail the request", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleQueryNilAuditLoggerIsNoOp(t *testing.T) {
|
||||
sr := &fakeSQLRunner{result: &executor.Result{Columns: []string{}, Rows: [][]any{}}}
|
||||
h := newTestHandler(sr, nil) // audit is nil here
|
||||
|
||||
rec := postQuery(t, h, `{"query": "SELECT 1"}`)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// fakeAuthorizer resolves every request to a fixed identity/error --
|
||||
// task 5 wired authz.RequireRoleOrService into RegisterRoutes but every
|
||||
// existing test above passes a nil authorizer (a deliberate no-op), so
|
||||
// none of them actually exercise the wiring with a real authorizer
|
||||
// present. Phase 4 task 8 (adversarial tests) closes that gap: these
|
||||
// prove /query's authz boundary holds when a real Authorizer is wired
|
||||
// in, not just that the middleware function works in isolation
|
||||
// (authz/middleware_test.go already covers that).
|
||||
type fakeAuthorizer struct {
|
||||
identity authz.Identity
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *fakeAuthorizer) Authorize(*http.Request) (authz.Identity, error) {
|
||||
return f.identity, f.err
|
||||
}
|
||||
|
||||
func newTestHandlerWithAuthorizer(sqlRunner *fakeSQLRunner, authorizer authz.Authorizer) *Handler {
|
||||
return NewHandler(slog.New(slog.NewTextHandler(io.Discard, nil)), sqlRunner, &fakeSearchClient{}, time.Second, nil, authorizer)
|
||||
}
|
||||
|
||||
func TestHandleQueryRejectsUnauthenticatedWhenAuthorizerConfigured(t *testing.T) {
|
||||
sr := &fakeSQLRunner{result: &executor.Result{Columns: []string{}, Rows: [][]any{}}}
|
||||
h := newTestHandlerWithAuthorizer(sr, &fakeAuthorizer{err: errors.New("no session")})
|
||||
|
||||
rec := postQuery(t, h, `{"query": "SELECT 1"}`)
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("status = %d, want 401", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleQueryAllowsViewer(t *testing.T) {
|
||||
sr := &fakeSQLRunner{result: &executor.Result{Columns: []string{}, Rows: [][]any{}}}
|
||||
h := newTestHandlerWithAuthorizer(sr, &fakeAuthorizer{identity: authz.Identity{TenantID: "acme", Role: authz.RoleViewer}})
|
||||
|
||||
rec := postQuery(t, h, `{"query": "SELECT 1"}`)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandleQueryAllowsServiceIdentity is the other half of the
|
||||
// alerting<->api gap's fix (/docs/phase-4-isolation-design.md) --
|
||||
// /alerting's evaluator must be able to call POST /query with its
|
||||
// RoleService credential even though it's not a human session.
|
||||
func TestHandleQueryAllowsServiceIdentity(t *testing.T) {
|
||||
sr := &fakeSQLRunner{result: &executor.Result{Columns: []string{}, Rows: [][]any{}}}
|
||||
h := newTestHandlerWithAuthorizer(sr, &fakeAuthorizer{identity: authz.Identity{Role: authz.RoleService}})
|
||||
|
||||
rec := postQuery(t, h, `{"query": "SELECT 1"}`)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200 -- RoleService must be allowed on /query; body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
// This file is a checklist, not a passing test suite -- it exists so
|
||||
// the four adversarial probes /docs/phase-4-isolation-design.md's
|
||||
// "Verification plan for this design specifically" section names for
|
||||
// Phase 4 task 8 have a permanent, grep-able home in the test tree,
|
||||
// even though none of them can run for real yet.
|
||||
//
|
||||
// Why they can't run: every one of these probes needs a *per-tenant*
|
||||
// ClickHouse user/database or Tantivy index to attack -- and none
|
||||
// exist. api/internal/querylang/executor.SQLRunner/SearchClient (the
|
||||
// only two interfaces api/internal/queryapi.Handler talks to) carry no
|
||||
// tenant field at all, confirmed by reading both interfaces; neither
|
||||
// does proto/sentry/search/v1/search.proto's SearchRequest. See
|
||||
// /docs/security/threat-model.md's "Read this first" section for the
|
||||
// full writeup -- there is currently exactly one shared ClickHouse
|
||||
// connection and one shared Tantivy index for every tenant, so "does
|
||||
// tenant A's connection leak tenant B's data" has no meaningful
|
||||
// operational answer yet: there's only one connection.
|
||||
//
|
||||
// Each Skip below names precisely what has to exist before that test
|
||||
// can be written for real (enterprise/internal/tenantprovision,
|
||||
// enterprise/internal/chrunner, enterprise/internal/searchclient -- all
|
||||
// still unbuilt, per the Phase 4 task 5 summary). Turning a Skip here
|
||||
// into a real assertion is the acceptance criterion for those packages,
|
||||
// not a nice-to-have follow-up.
|
||||
package queryapi
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestAdversarial_ClickHouseUserCannotReadOtherTenantDatabaseByFullyQualifiedName(t *testing.T) {
|
||||
t.Skip("BLOCKED on enterprise/internal/tenantprovision + enterprise/internal/chrunner: " +
|
||||
"needs two real per-tenant ClickHouse users/databases to attempt " +
|
||||
"`SELECT * FROM other_tenant_db.logs` against. See " +
|
||||
"/docs/phase-4-isolation-design.md's verification plan, item 1.")
|
||||
}
|
||||
|
||||
func TestAdversarial_ClickHouseUserCannotReadSystemTables(t *testing.T) {
|
||||
t.Skip("BLOCKED on enterprise/internal/tenantprovision: needs a real per-tenant " +
|
||||
"ClickHouse user to attempt `SELECT * FROM system.query_log`, " +
|
||||
"`system.tables`, `SHOW DATABASES` against, and confirm system.* " +
|
||||
"access was actually revoked (not just assumed from ClickHouse's " +
|
||||
"default template -- task 2's finding was that this is " +
|
||||
"version-dependent and must be checked live, not read from docs). " +
|
||||
"See /docs/phase-4-isolation-design.md's verification plan, item 2.")
|
||||
}
|
||||
|
||||
func TestAdversarial_TantivySearchExcludesOtherTenantsMatchingResults(t *testing.T) {
|
||||
t.Skip("BLOCKED on enterprise/internal/searchclient: needs two real " +
|
||||
"per-tenant Tantivy indices, one seeded with a term, to confirm a " +
|
||||
"search scoped to the other tenant returns zero hits for that term " +
|
||||
"even though the term exists in the other index. See " +
|
||||
"/docs/phase-4-isolation-design.md's verification plan, item 3.")
|
||||
}
|
||||
|
||||
func TestAdversarial_EvaluatorTickMidProvisioningIsRefusedNotServed(t *testing.T) {
|
||||
t.Skip("BLOCKED on enterprise/internal/tenantprovision's ordered " +
|
||||
"provisioning state machine (CREATE USER -> GRANT -> mark active): " +
|
||||
"needs a tenant row that exists but hasn't reached the active gate " +
|
||||
"yet, and a simulated /alerting evaluator tick against it, to " +
|
||||
"confirm every tenant-resolution path actually checks tenant " +
|
||||
"status server-side rather than inferring readiness from ambient " +
|
||||
"connection success. See /docs/phase-4-isolation-design.md's " +
|
||||
"verification plan, item 4, and its provisioning-gate requirement.")
|
||||
}
|
||||
@@ -74,7 +74,7 @@ func (FreeText) isTerm() {}
|
||||
type TimeExpr struct {
|
||||
Absolute string
|
||||
IsRelative bool
|
||||
RelativeSign int // -1 or +1
|
||||
RelativeSign int // -1 or +1
|
||||
RelativeN int
|
||||
RelativeUnit string // "s" | "m" | "h" | "d" | "w"
|
||||
}
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
package executor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"reflect"
|
||||
|
||||
"github.com/ClickHouse/clickhouse-go/v2/lib/driver"
|
||||
)
|
||||
|
||||
// ChRunner runs arbitrary (pre-validated) SELECT statements against
|
||||
// ClickHouse and shapes the result into JSON-friendly columns/rows,
|
||||
// discovering the result's column set at query time via reflection since
|
||||
// the query itself is arbitrary. Ported from Phase 0/1's
|
||||
// api/internal/queryapi.Executor, which this replaces (see task 4) --
|
||||
// same logic, moved here since it's the query-execution layer's
|
||||
// plumbing, not specific to the old placeholder /query handler.
|
||||
type ChRunner struct {
|
||||
conn driver.Conn
|
||||
}
|
||||
|
||||
func NewChRunner(conn driver.Conn) *ChRunner {
|
||||
return &ChRunner{conn: conn}
|
||||
}
|
||||
|
||||
func (r *ChRunner) RunSQL(ctx context.Context, sql string) (*Result, error) {
|
||||
rows, err := r.conn.Query(ctx, sql)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("executing query: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
columnTypes := rows.ColumnTypes()
|
||||
result := &Result{
|
||||
Columns: rows.Columns(),
|
||||
Rows: [][]any{},
|
||||
}
|
||||
|
||||
for rows.Next() {
|
||||
dest := make([]any, len(columnTypes))
|
||||
for i, ct := range columnTypes {
|
||||
dest[i] = reflect.New(ct.ScanType()).Interface()
|
||||
}
|
||||
if err := rows.Scan(dest...); err != nil {
|
||||
return nil, fmt.Errorf("scanning row: %w", err)
|
||||
}
|
||||
row := make([]any, len(dest))
|
||||
for i, d := range dest {
|
||||
row[i] = reflect.ValueOf(d).Elem().Interface()
|
||||
}
|
||||
result.Rows = append(result.Rows, row)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("iterating rows: %w", err)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
// Package executor runs a compiled ir.Plan and returns results in a
|
||||
// shape consistent regardless of which backend(s) were hit -- the point
|
||||
// of compiling to one IR in the first place. See
|
||||
// /docs/query-language-design.md's "Execution" section for the four
|
||||
// routing cases implemented here.
|
||||
package executor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/sentry/sentry/api/internal/querylang/ir"
|
||||
)
|
||||
|
||||
type Result struct {
|
||||
Columns []string
|
||||
Rows [][]any
|
||||
}
|
||||
|
||||
// SQLRunner executes a raw SQL statement against ClickHouse. *ChRunner
|
||||
// (chrunner.go) is the production implementation; tests use a fake --
|
||||
// same narrow-interface pattern used throughout /ingest and /api.
|
||||
type SQLRunner interface {
|
||||
RunSQL(ctx context.Context, sql string) (*Result, error)
|
||||
}
|
||||
|
||||
// SearchClient resolves a Tantivy query into matching record_ids.
|
||||
type SearchClient interface {
|
||||
Search(ctx context.Context, query string, limit uint32) ([]string, error)
|
||||
}
|
||||
|
||||
// textSearchLimit caps how many record_ids a Tantivy prefilter can feed
|
||||
// into a ClickHouse `IN (...)` clause. See /docs/query-language-design.md's
|
||||
// "Known scaling limitation" -- this is a real, disclosed limit on result
|
||||
// completeness for very broad text searches, not an oversight.
|
||||
//
|
||||
// 5000, not 10000: confirmed by actually running the Phase 2 benchmark
|
||||
// (see /docs/phase-2-runbook.md) that 10000 quoted UUIDs (~39 bytes each
|
||||
// including the comma) produces a ~390KB query string, which exceeds
|
||||
// ClickHouse's default max_query_size (262144 bytes / 256KiB) and fails
|
||||
// outright with a syntax error rather than degrading gracefully. 5000
|
||||
// UUIDs is ~195KB, safely under that default with headroom for the rest
|
||||
// of the query. This was a real failure caught by running the benchmark,
|
||||
// not a value chosen from first-principles estimation.
|
||||
const textSearchLimit = 5000
|
||||
|
||||
// Execute runs plan against the given backends. The four cases (per the
|
||||
// design doc): RawSQL passthrough; pure ClickHouse (no TextSearch); text
|
||||
// search alone (Tantivy prefilter -> ClickHouse row fetch); text search
|
||||
// plus aggregation (Tantivy prefilter -> ClickHouse aggregate). Cases 2-4
|
||||
// share the same buildSQL/buildWhereClause code (sql.go) -- the only
|
||||
// difference is whether a record_id filter is threaded in.
|
||||
func Execute(ctx context.Context, plan *ir.Plan, sqlRunner SQLRunner, search SearchClient) (*Result, error) {
|
||||
if plan.RawSQL != "" {
|
||||
return sqlRunner.RunSQL(ctx, plan.RawSQL)
|
||||
}
|
||||
|
||||
var recordIDFilter []string
|
||||
if len(plan.TextSearch) > 0 {
|
||||
ids, err := search.Search(ctx, plan.TextSearch[0].Query, textSearchLimit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("full-text search failed: %w", err)
|
||||
}
|
||||
if len(ids) == 0 {
|
||||
return &Result{Columns: []string{}, Rows: [][]any{}}, nil
|
||||
}
|
||||
recordIDFilter = ids
|
||||
}
|
||||
|
||||
sql := buildSQL(plan, recordIDFilter)
|
||||
return sqlRunner.RunSQL(ctx, sql)
|
||||
}
|
||||
@@ -1,333 +0,0 @@
|
||||
package executor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/sentry/sentry/api/internal/querylang/ir"
|
||||
)
|
||||
|
||||
func mustParseTime(t *testing.T, s string) time.Time {
|
||||
t.Helper()
|
||||
tm, err := time.Parse(time.RFC3339, s)
|
||||
if err != nil {
|
||||
t.Fatalf("parsing time %q: %v", s, err)
|
||||
}
|
||||
return tm
|
||||
}
|
||||
|
||||
type fakeSQLRunner struct {
|
||||
gotSQL string
|
||||
result *Result
|
||||
err error
|
||||
calls int
|
||||
}
|
||||
|
||||
func (f *fakeSQLRunner) RunSQL(_ context.Context, sql string) (*Result, error) {
|
||||
f.gotSQL = sql
|
||||
f.calls++
|
||||
if f.err != nil {
|
||||
return nil, f.err
|
||||
}
|
||||
if f.result != nil {
|
||||
return f.result, nil
|
||||
}
|
||||
return &Result{Columns: []string{}, Rows: [][]any{}}, nil
|
||||
}
|
||||
|
||||
type fakeSearchClient struct {
|
||||
gotQuery string
|
||||
gotLimit uint32
|
||||
ids []string
|
||||
err error
|
||||
calls int
|
||||
}
|
||||
|
||||
func (f *fakeSearchClient) Search(_ context.Context, query string, limit uint32) ([]string, error) {
|
||||
f.gotQuery = query
|
||||
f.gotLimit = limit
|
||||
f.calls++
|
||||
if f.err != nil {
|
||||
return nil, f.err
|
||||
}
|
||||
return f.ids, nil
|
||||
}
|
||||
|
||||
func TestExecuteRawSQLBypassesEverythingElse(t *testing.T) {
|
||||
sqlRunner := &fakeSQLRunner{}
|
||||
search := &fakeSearchClient{}
|
||||
plan := &ir.Plan{RawSQL: "SELECT 1"}
|
||||
|
||||
_, err := Execute(context.Background(), plan, sqlRunner, search)
|
||||
if err != nil {
|
||||
t.Fatalf("Execute() error = %v", err)
|
||||
}
|
||||
if sqlRunner.gotSQL != "SELECT 1" {
|
||||
t.Fatalf("gotSQL = %q, want %q", sqlRunner.gotSQL, "SELECT 1")
|
||||
}
|
||||
if search.calls != 0 {
|
||||
t.Fatalf("expected search not to be called for RawSQL, got %d calls", search.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecutePureClickHousePathSkipsSearch(t *testing.T) {
|
||||
sqlRunner := &fakeSQLRunner{}
|
||||
search := &fakeSearchClient{}
|
||||
plan := &ir.Plan{Filters: []ir.FilterPredicate{{Field: "service", Op: "=", Value: "api"}}}
|
||||
|
||||
_, err := Execute(context.Background(), plan, sqlRunner, search)
|
||||
if err != nil {
|
||||
t.Fatalf("Execute() error = %v", err)
|
||||
}
|
||||
if search.calls != 0 {
|
||||
t.Fatalf("expected no search calls, got %d", search.calls)
|
||||
}
|
||||
if !strings.Contains(sqlRunner.gotSQL, "FROM logs") || !strings.Contains(sqlRunner.gotSQL, "`service` = 'api'") {
|
||||
t.Fatalf("unexpected SQL: %s", sqlRunner.gotSQL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteTextSearchPrefiltersThenQueriesClickHouse(t *testing.T) {
|
||||
sqlRunner := &fakeSQLRunner{}
|
||||
search := &fakeSearchClient{ids: []string{"id-1", "id-2"}}
|
||||
plan := &ir.Plan{TextSearch: []ir.TextPredicate{{Query: "connection refused"}}}
|
||||
|
||||
_, err := Execute(context.Background(), plan, sqlRunner, search)
|
||||
if err != nil {
|
||||
t.Fatalf("Execute() error = %v", err)
|
||||
}
|
||||
if search.gotQuery != "connection refused" {
|
||||
t.Fatalf("search query = %q", search.gotQuery)
|
||||
}
|
||||
if search.gotLimit != textSearchLimit {
|
||||
t.Fatalf("search limit = %d, want %d", search.gotLimit, textSearchLimit)
|
||||
}
|
||||
if !strings.Contains(sqlRunner.gotSQL, "record_id IN ('id-1','id-2')") {
|
||||
t.Fatalf("unexpected SQL: %s", sqlRunner.gotSQL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteTextSearchNoMatchesSkipsClickHouseEntirely(t *testing.T) {
|
||||
sqlRunner := &fakeSQLRunner{}
|
||||
search := &fakeSearchClient{ids: nil}
|
||||
plan := &ir.Plan{TextSearch: []ir.TextPredicate{{Query: "nothing matches"}}}
|
||||
|
||||
result, err := Execute(context.Background(), plan, sqlRunner, search)
|
||||
if err != nil {
|
||||
t.Fatalf("Execute() error = %v", err)
|
||||
}
|
||||
if sqlRunner.calls != 0 {
|
||||
t.Fatalf("expected ClickHouse not to be queried when search finds nothing, got %d calls", sqlRunner.calls)
|
||||
}
|
||||
if len(result.Columns) != 0 || len(result.Rows) != 0 {
|
||||
t.Fatalf("expected empty result, got %+v", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteTextSearchWithAggregation(t *testing.T) {
|
||||
sqlRunner := &fakeSQLRunner{}
|
||||
search := &fakeSearchClient{ids: []string{"id-1"}}
|
||||
plan := &ir.Plan{
|
||||
TextSearch: []ir.TextPredicate{{Query: "connection refused"}},
|
||||
Aggregation: &ir.Aggregation{
|
||||
Funcs: []ir.AggFunc{{Func: "count", Alias: "count"}},
|
||||
GroupBy: []string{"host"},
|
||||
},
|
||||
}
|
||||
|
||||
_, err := Execute(context.Background(), plan, sqlRunner, search)
|
||||
if err != nil {
|
||||
t.Fatalf("Execute() error = %v", err)
|
||||
}
|
||||
if !strings.Contains(sqlRunner.gotSQL, "record_id IN ('id-1')") {
|
||||
t.Fatalf("expected the text-search prefilter in the WHERE clause: %s", sqlRunner.gotSQL)
|
||||
}
|
||||
if !strings.Contains(sqlRunner.gotSQL, "GROUP BY `host`") {
|
||||
t.Fatalf("expected GROUP BY: %s", sqlRunner.gotSQL)
|
||||
}
|
||||
if !strings.Contains(sqlRunner.gotSQL, "count() AS `count`") {
|
||||
t.Fatalf("expected count() AS `count`: %s", sqlRunner.gotSQL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteSearchErrorPropagates(t *testing.T) {
|
||||
sqlRunner := &fakeSQLRunner{}
|
||||
search := &fakeSearchClient{err: errors.New("search unavailable")}
|
||||
plan := &ir.Plan{TextSearch: []ir.TextPredicate{{Query: "x"}}}
|
||||
|
||||
_, err := Execute(context.Background(), plan, sqlRunner, search)
|
||||
if err == nil {
|
||||
t.Fatal("expected the search error to propagate")
|
||||
}
|
||||
if sqlRunner.calls != 0 {
|
||||
t.Fatalf("expected ClickHouse not to be queried after a search error, got %d calls", sqlRunner.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildSQLNumericCastOnAttributesField(t *testing.T) {
|
||||
plan := &ir.Plan{Filters: []ir.FilterPredicate{{Field: "status", Op: ">=", Value: "500"}}}
|
||||
sql := buildSQL(plan, nil)
|
||||
want := "toFloat64OrZero(attributes['status']) >= 500"
|
||||
if !strings.Contains(sql, want) {
|
||||
t.Fatalf("SQL = %q, want it to contain %q", sql, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildSQLStringComparisonOnAttributesField(t *testing.T) {
|
||||
plan := &ir.Plan{Filters: []ir.FilterPredicate{{Field: "status", Op: "=", Value: "unknown"}}}
|
||||
sql := buildSQL(plan, nil)
|
||||
want := "attributes['status'] = 'unknown'"
|
||||
if !strings.Contains(sql, want) {
|
||||
t.Fatalf("SQL = %q, want it to contain %q", sql, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildSQLTopLevelFieldNeverCast(t *testing.T) {
|
||||
plan := &ir.Plan{Filters: []ir.FilterPredicate{{Field: "service", Op: "=", Value: "123"}}}
|
||||
sql := buildSQL(plan, nil)
|
||||
if strings.Contains(sql, "toFloat64OrZero") {
|
||||
t.Fatalf("top-level field should never be numeric-cast: %s", sql)
|
||||
}
|
||||
if !strings.Contains(sql, "`service` = '123'") {
|
||||
t.Fatalf("unexpected SQL: %s", sql)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildSQLEscapesInjectionAttemptInValue(t *testing.T) {
|
||||
plan := &ir.Plan{Filters: []ir.FilterPredicate{{Field: "service", Op: "=", Value: "x'; DROP TABLE logs; --"}}}
|
||||
sql := buildSQL(plan, nil)
|
||||
// The whole attacker-controlled value must land inside exactly one
|
||||
// quoted literal, with its embedded quote backslash-escaped so it
|
||||
// can't terminate the literal early -- checking for the escaped
|
||||
// form directly, not just the absence of the raw substring (which
|
||||
// is a weaker check: "\\'; DROP TABLE" still *contains* "'; DROP
|
||||
// TABLE" as a substring, so that alone doesn't prove escaping
|
||||
// happened).
|
||||
want := `'x\'; DROP TABLE logs; --'`
|
||||
if !strings.Contains(sql, want) {
|
||||
t.Fatalf("expected the literal %q in SQL, got: %s", want, sql)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildSQLDefaultLimitAppliedWhenNoneGiven(t *testing.T) {
|
||||
plan := &ir.Plan{Filters: []ir.FilterPredicate{{Field: "service", Op: "=", Value: "api"}}}
|
||||
sql := buildSQL(plan, nil)
|
||||
if !strings.Contains(sql, "LIMIT 100") {
|
||||
t.Fatalf("expected the default row limit, got: %s", sql)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildSQLExplicitLimitOverridesDefault(t *testing.T) {
|
||||
plan := &ir.Plan{
|
||||
Filters: []ir.FilterPredicate{{Field: "service", Op: "=", Value: "api"}},
|
||||
Limit: &ir.Limit{N: 5},
|
||||
}
|
||||
sql := buildSQL(plan, nil)
|
||||
if !strings.Contains(sql, "LIMIT 5") || strings.Contains(sql, "LIMIT 100") {
|
||||
t.Fatalf("expected LIMIT 5, got: %s", sql)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildSQLTailWithoutSortOrdersAscending(t *testing.T) {
|
||||
plan := &ir.Plan{Limit: &ir.Limit{N: 10, Tail: true}}
|
||||
sql := buildSQL(plan, nil)
|
||||
if !strings.Contains(sql, "ORDER BY `timestamp` ASC") {
|
||||
t.Fatalf("expected ascending order for tail, got: %s", sql)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildSQLNoSortDefaultsNewestFirst(t *testing.T) {
|
||||
plan := &ir.Plan{}
|
||||
sql := buildSQL(plan, nil)
|
||||
if !strings.Contains(sql, "ORDER BY `timestamp` DESC") {
|
||||
t.Fatalf("expected newest-first default, got: %s", sql)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildSQLSortByAggregateAlias(t *testing.T) {
|
||||
plan := &ir.Plan{
|
||||
Aggregation: &ir.Aggregation{
|
||||
Funcs: []ir.AggFunc{{Func: "count", Alias: "count"}},
|
||||
GroupBy: []string{"host"},
|
||||
},
|
||||
Sort: []ir.SortField{{Field: "count", Desc: true}},
|
||||
}
|
||||
sql := buildSQL(plan, nil)
|
||||
if !strings.Contains(sql, "ORDER BY `count` DESC") {
|
||||
t.Fatalf("expected ORDER BY on the aggregate alias, got: %s", sql)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildSQLSortByGroupByField(t *testing.T) {
|
||||
plan := &ir.Plan{
|
||||
Aggregation: &ir.Aggregation{
|
||||
Funcs: []ir.AggFunc{{Func: "count", Alias: "count"}},
|
||||
GroupBy: []string{"host"},
|
||||
},
|
||||
Sort: []ir.SortField{{Field: "host", Desc: false}},
|
||||
}
|
||||
sql := buildSQL(plan, nil)
|
||||
if !strings.Contains(sql, "ORDER BY `host` ASC") {
|
||||
t.Fatalf("expected ORDER BY on the group-by column, got: %s", sql)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildSQLAggregationOnAttributesFieldAlwaysCasts(t *testing.T) {
|
||||
plan := &ir.Plan{
|
||||
Aggregation: &ir.Aggregation{
|
||||
Funcs: []ir.AggFunc{{Func: "avg", Field: "latency_ms", Alias: "avg_latency"}},
|
||||
},
|
||||
}
|
||||
sql := buildSQL(plan, nil)
|
||||
if !strings.Contains(sql, "AVG(toFloat64OrZero(attributes['latency_ms'])) AS `avg_latency`") {
|
||||
t.Fatalf("unexpected SQL: %s", sql)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildSQLProjectionFields(t *testing.T) {
|
||||
plan := &ir.Plan{Fields: []string{"host", "message"}}
|
||||
sql := buildSQL(plan, nil)
|
||||
if !strings.Contains(sql, "SELECT `host` AS `host`, `message` AS `message` FROM logs") {
|
||||
t.Fatalf("unexpected SQL: %s", sql)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildSQLTimeRange(t *testing.T) {
|
||||
from := mustParseTime(t, "2026-08-14T00:00:00Z")
|
||||
to := mustParseTime(t, "2026-08-14T01:00:00Z")
|
||||
plan := &ir.Plan{TimeRange: &ir.TimeRange{From: from, To: to}}
|
||||
sql := buildSQL(plan, nil)
|
||||
// Space-separated, no 'T'/'Z' -- ClickHouse's implicit string->DateTime64
|
||||
// cast for a column-vs-literal comparison is strict and rejects
|
||||
// RFC3339/ISO-8601 shaped literals ("code: 53, Cannot convert string...
|
||||
// to type DateTime64(9, 'UTC')"), confirmed by actually running a
|
||||
// dashboard panel with earliest= against live ClickHouse -- this test
|
||||
// previously asserted the RFC3339 shape that ClickHouse rejects, which
|
||||
// is exactly how the bug went unnoticed: nothing here ever executed the
|
||||
// SQL against a real database.
|
||||
if !strings.Contains(sql, "`timestamp` >= '2026-08-14 00:00:00'") {
|
||||
t.Fatalf("missing From bound: %s", sql)
|
||||
}
|
||||
if !strings.Contains(sql, "`timestamp` <= '2026-08-14 01:00:00'") {
|
||||
t.Fatalf("missing To bound: %s", sql)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatClickHouseDateTime64OmitsTrailingZeroFraction(t *testing.T) {
|
||||
// time.Time's default zero-value fractional seconds must not leave a
|
||||
// stray "." with nothing after it -- Format's `.999999999` verb
|
||||
// already handles this (trims to nothing when the fraction is zero),
|
||||
// but it's worth pinning down given how easy the RFC3339Nano mistake
|
||||
// was to miss in the first place.
|
||||
got := formatClickHouseDateTime64(mustParseTime(t, "2026-08-14T00:00:00Z"))
|
||||
if got != "2026-08-14 00:00:00" {
|
||||
t.Fatalf("got %q, want no trailing fractional-seconds dot", got)
|
||||
}
|
||||
got = formatClickHouseDateTime64(mustParseTime(t, "2026-08-14T00:00:00.223505479Z"))
|
||||
if got != "2026-08-14 00:00:00.223505479" {
|
||||
t.Fatalf("got %q", got)
|
||||
}
|
||||
}
|
||||
@@ -1,247 +0,0 @@
|
||||
package executor
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/sentry/sentry/api/internal/querylang/ir"
|
||||
)
|
||||
|
||||
// defaultRowLimit is the safety net when a raw-row query has neither an
|
||||
// explicit head/tail nor an aggregation -- without it, a bare `service=api`
|
||||
// with no other pipe stages would return every matching row unbounded.
|
||||
// Independent of planner's own defaultLimit (same value, different
|
||||
// concern: that one fills in `head`/`tail` with no N given; this one
|
||||
// guards queries that never mention head/tail at all).
|
||||
const defaultRowLimit = 100
|
||||
|
||||
// logs' real columns, per /storage. Anything else maps to
|
||||
// attributes['field'] -- see /docs/query-language-design.md's "Field
|
||||
// mapping" section.
|
||||
var topLevelFields = map[string]bool{
|
||||
"timestamp": true,
|
||||
"host": true,
|
||||
"service": true,
|
||||
"severity": true,
|
||||
"message": true,
|
||||
"record_id": true,
|
||||
}
|
||||
|
||||
func buildSQL(plan *ir.Plan, recordIDFilter []string) string {
|
||||
var sb strings.Builder
|
||||
|
||||
sb.WriteString("SELECT ")
|
||||
sb.WriteString(selectClause(plan))
|
||||
sb.WriteString(" FROM logs")
|
||||
|
||||
if where := buildWhereClause(plan, recordIDFilter); where != "" {
|
||||
sb.WriteString(" WHERE ")
|
||||
sb.WriteString(where)
|
||||
}
|
||||
|
||||
if plan.Aggregation != nil && len(plan.Aggregation.GroupBy) > 0 {
|
||||
sb.WriteString(" GROUP BY ")
|
||||
cols := make([]string, len(plan.Aggregation.GroupBy))
|
||||
for i, g := range plan.Aggregation.GroupBy {
|
||||
cols[i] = columnExpr(g)
|
||||
}
|
||||
sb.WriteString(strings.Join(cols, ", "))
|
||||
}
|
||||
|
||||
writeOrderBy(&sb, plan)
|
||||
|
||||
if plan.Limit != nil {
|
||||
fmt.Fprintf(&sb, " LIMIT %d", plan.Limit.N)
|
||||
} else if plan.Aggregation == nil {
|
||||
fmt.Fprintf(&sb, " LIMIT %d", defaultRowLimit)
|
||||
}
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
func writeOrderBy(sb *strings.Builder, plan *ir.Plan) {
|
||||
switch {
|
||||
case len(plan.Sort) > 0:
|
||||
sb.WriteString(" ORDER BY ")
|
||||
parts := make([]string, len(plan.Sort))
|
||||
for i, s := range plan.Sort {
|
||||
dir := "ASC"
|
||||
if s.Desc {
|
||||
dir = "DESC"
|
||||
}
|
||||
parts[i] = sortColumnExpr(plan, s.Field) + " " + dir
|
||||
}
|
||||
sb.WriteString(strings.Join(parts, ", "))
|
||||
case plan.Limit != nil && plan.Limit.Tail:
|
||||
// `tail N` with no explicit sort: order ascending so LIMIT N
|
||||
// takes the chronologically *last* N rows. Callers wanting
|
||||
// strict newest-first display order re-sort client-side --
|
||||
// documented in the query language reference.
|
||||
sb.WriteString(" ORDER BY `timestamp` ASC")
|
||||
case plan.Aggregation == nil:
|
||||
// Raw-row queries with no explicit sort default to newest-first,
|
||||
// matching the Phase 0/1 UI default.
|
||||
sb.WriteString(" ORDER BY `timestamp` DESC")
|
||||
}
|
||||
}
|
||||
|
||||
// sortColumnExpr resolves a sort field against an aggregation's own
|
||||
// output columns (alias or group-by field) before falling back to the
|
||||
// normal top-level/attributes mapping -- `sort -count` after `stats
|
||||
// count` refers to the aggregate's alias, not a raw column.
|
||||
func sortColumnExpr(plan *ir.Plan, field string) string {
|
||||
if plan.Aggregation != nil {
|
||||
for _, f := range plan.Aggregation.Funcs {
|
||||
if f.Alias == field {
|
||||
return quoteIdent(field)
|
||||
}
|
||||
}
|
||||
for _, g := range plan.Aggregation.GroupBy {
|
||||
if g == field {
|
||||
return columnExpr(field)
|
||||
}
|
||||
}
|
||||
}
|
||||
return columnExpr(field)
|
||||
}
|
||||
|
||||
func selectClause(plan *ir.Plan) string {
|
||||
if plan.Aggregation != nil {
|
||||
parts := make([]string, 0, len(plan.Aggregation.GroupBy)+len(plan.Aggregation.Funcs))
|
||||
for _, g := range plan.Aggregation.GroupBy {
|
||||
parts = append(parts, columnExpr(g)+" AS "+quoteIdent(g))
|
||||
}
|
||||
for _, f := range plan.Aggregation.Funcs {
|
||||
parts = append(parts, aggExpr(f)+" AS "+quoteIdent(f.Alias))
|
||||
}
|
||||
return strings.Join(parts, ", ")
|
||||
}
|
||||
if len(plan.Fields) > 0 {
|
||||
parts := make([]string, len(plan.Fields))
|
||||
for i, f := range plan.Fields {
|
||||
parts[i] = columnExpr(f) + " AS " + quoteIdent(f)
|
||||
}
|
||||
return strings.Join(parts, ", ")
|
||||
}
|
||||
return "*"
|
||||
}
|
||||
|
||||
// aggExpr always numeric-casts non-top-level (attributes-map) fields for
|
||||
// sum/avg/min/max, unlike comparison predicates where casting is
|
||||
// conditional on whether the compared value looks numeric -- an
|
||||
// aggregate function is inherently a numeric (or, for min/max,
|
||||
// order-comparable) operation, so there's no "maybe string" case the way
|
||||
// there is for `field=value`. Known Phase 2 limitation: min/max on a
|
||||
// non-top-level field always compares numerically, not lexicographically
|
||||
// -- string min/max on attributes isn't supported this phase.
|
||||
func aggExpr(f ir.AggFunc) string {
|
||||
if f.Func == "count" {
|
||||
return "count()"
|
||||
}
|
||||
col := columnExpr(f.Field)
|
||||
if !topLevelFields[f.Field] {
|
||||
col = "toFloat64OrZero(" + col + ")"
|
||||
}
|
||||
return strings.ToUpper(f.Func) + "(" + col + ")"
|
||||
}
|
||||
|
||||
func buildWhereClause(plan *ir.Plan, recordIDFilter []string) string {
|
||||
var conds []string
|
||||
|
||||
if len(recordIDFilter) > 0 {
|
||||
quoted := make([]string, len(recordIDFilter))
|
||||
for i, id := range recordIDFilter {
|
||||
quoted[i] = quoteLiteral(id)
|
||||
}
|
||||
conds = append(conds, "record_id IN ("+strings.Join(quoted, ",")+")")
|
||||
}
|
||||
|
||||
for _, f := range plan.Filters {
|
||||
conds = append(conds, buildComparisonSQL(f))
|
||||
}
|
||||
|
||||
if plan.TimeRange != nil {
|
||||
if !plan.TimeRange.From.IsZero() {
|
||||
conds = append(conds, "`timestamp` >= "+quoteLiteral(formatClickHouseDateTime64(plan.TimeRange.From)))
|
||||
}
|
||||
if !plan.TimeRange.To.IsZero() {
|
||||
conds = append(conds, "`timestamp` <= "+quoteLiteral(formatClickHouseDateTime64(plan.TimeRange.To)))
|
||||
}
|
||||
}
|
||||
|
||||
return strings.Join(conds, " AND ")
|
||||
}
|
||||
|
||||
// formatClickHouseDateTime64 formats t the way ClickHouse's implicit
|
||||
// string->DateTime64 CAST expects for a WHERE-clause comparison:
|
||||
// "YYYY-MM-DD HH:MM:SS[.fractional]", space-separated, no 'T'/'Z'. This
|
||||
// is a real, measured requirement, not a guess: an ISO-8601/RFC3339Nano
|
||||
// literal (e.g. "2026-08-12T20:17:40.223505479Z", what time.RFC3339Nano
|
||||
// produces) fails at query time with "code: 53, Cannot convert string
|
||||
// ... to type DateTime64(9, 'UTC')" -- ClickHouse's *implicit* cast used
|
||||
// for column-vs-literal comparisons is strict, unlike the lenient
|
||||
// parseDateTimeBestEffort used elsewhere in ClickHouse. Found by
|
||||
// actually running a dashboard panel with a relative earliest= against
|
||||
// live ClickHouse (Phase 2's own unit tests never caught this: they
|
||||
// assert against a fake SQLRunner that checks the generated SQL string,
|
||||
// not that ClickHouse accepts it, and none of Phase 2's own live-stack
|
||||
// runbook queries happened to use earliest=/latest= at all).
|
||||
func formatClickHouseDateTime64(t time.Time) string {
|
||||
return t.UTC().Format("2006-01-02 15:04:05.999999999")
|
||||
}
|
||||
|
||||
// buildComparisonSQL numeric-casts a non-top-level field only when the
|
||||
// compared value itself looks numeric -- `status>=500` casts (numeric
|
||||
// comparison intent), `status="unknown"` doesn't (string comparison
|
||||
// intent). Top-level fields are never cast; ClickHouse compares them
|
||||
// against a string literal natively (LowCardinality(String)/String
|
||||
// compare as-is; DateTime64 columns need formatClickHouseDateTime64's
|
||||
// exact literal shape, handled in buildWhereClause above, not here).
|
||||
func buildComparisonSQL(f ir.FilterPredicate) string {
|
||||
if !topLevelFields[f.Field] && isNumericLiteral(f.Value) {
|
||||
return "toFloat64OrZero(" + columnExpr(f.Field) + ") " + f.Op + " " + f.Value
|
||||
}
|
||||
return columnExpr(f.Field) + " " + f.Op + " " + quoteLiteral(f.Value)
|
||||
}
|
||||
|
||||
func columnExpr(field string) string {
|
||||
if topLevelFields[field] {
|
||||
return quoteIdent(field)
|
||||
}
|
||||
return "attributes[" + quoteLiteral(field) + "]"
|
||||
}
|
||||
|
||||
func quoteIdent(name string) string {
|
||||
return "`" + strings.ReplaceAll(name, "`", "``") + "`"
|
||||
}
|
||||
|
||||
// quoteLiteral is the actual injection defense for every user-controlled
|
||||
// string embedded in generated SQL (filter values, attribute keys, time
|
||||
// bounds, record_ids). Field/keyword tokens from the lexer are already
|
||||
// constrained to [a-zA-Z0-9_.] by construction (see lexer.isIdentPart)
|
||||
// and can't carry SQL metacharacters at all, but quoted-string *values*
|
||||
// can contain anything, so this can't be skipped for them.
|
||||
func quoteLiteral(s string) string {
|
||||
var sb strings.Builder
|
||||
sb.WriteByte('\'')
|
||||
for _, r := range s {
|
||||
switch r {
|
||||
case '\\':
|
||||
sb.WriteString(`\\`)
|
||||
case '\'':
|
||||
sb.WriteString(`\'`)
|
||||
default:
|
||||
sb.WriteRune(r)
|
||||
}
|
||||
}
|
||||
sb.WriteByte('\'')
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
var numericLiteralRe = regexp.MustCompile(`^-?\d+(\.\d+)?$`)
|
||||
|
||||
func isNumericLiteral(s string) bool {
|
||||
return numericLiteralRe.MatchString(s)
|
||||
}
|
||||
@@ -12,23 +12,23 @@ type Kind int
|
||||
const (
|
||||
EOF Kind = iota
|
||||
Illegal
|
||||
Ident // bare words: field names, keywords, unquoted values/free-text terms
|
||||
String // quoted string: "..."
|
||||
Number // 123, 1.5
|
||||
Pipe // |
|
||||
Eq // =
|
||||
Neq // !=
|
||||
Gt // >
|
||||
Gte // >=
|
||||
Lt // <
|
||||
Lte // <=
|
||||
Colon // :
|
||||
Comma // ,
|
||||
LParen // (
|
||||
RParen // )
|
||||
Minus // -
|
||||
Plus // +
|
||||
Star // * (only meaningful inside count(*), same as SQL)
|
||||
Ident // bare words: field names, keywords, unquoted values/free-text terms
|
||||
String // quoted string: "..."
|
||||
Number // 123, 1.5
|
||||
Pipe // |
|
||||
Eq // =
|
||||
Neq // !=
|
||||
Gt // >
|
||||
Gte // >=
|
||||
Lt // <
|
||||
Lte // <=
|
||||
Colon // :
|
||||
Comma // ,
|
||||
LParen // (
|
||||
RParen // )
|
||||
Minus // -
|
||||
Plus // +
|
||||
Star // * (only meaningful inside count(*), same as SQL)
|
||||
)
|
||||
|
||||
type Token struct {
|
||||
|
||||
@@ -18,7 +18,7 @@ import (
|
||||
type Language string
|
||||
|
||||
const (
|
||||
Auto Language = "" // detect from the query text (default)
|
||||
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
|
||||
)
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
// Package searchclient adapts the generated gRPC SearchServiceClient to
|
||||
// the narrow querylang/executor.SearchClient interface (Search(ctx,
|
||||
// query string, limit uint32) ([]string, error), satisfied structurally,
|
||||
// no adapter type needed), so the query executor doesn't need to know
|
||||
// anything about gRPC/protobuf directly.
|
||||
package searchclient
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/credentials/insecure"
|
||||
|
||||
searchv1 "github.com/sentry/sentry/proto/sentry/search/v1"
|
||||
)
|
||||
|
||||
type Client struct {
|
||||
grpc searchv1.SearchServiceClient
|
||||
conn *grpc.ClientConn
|
||||
}
|
||||
|
||||
// Dial connects to the search service. Plain TCP, no TLS: internal
|
||||
// service-to-service traffic (api <-> search), same trust boundary as
|
||||
// api's existing plain-TCP connection to ClickHouse -- mTLS in this
|
||||
// project is specifically the agent<->ingest edge boundary, not every
|
||||
// internal hop.
|
||||
func Dial(addr string) (*Client, error) {
|
||||
conn, err := grpc.NewClient(addr, grpc.WithTransportCredentials(insecure.NewCredentials()))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("dialing search service at %s: %w", addr, err)
|
||||
}
|
||||
return &Client{grpc: searchv1.NewSearchServiceClient(conn), conn: conn}, nil
|
||||
}
|
||||
|
||||
func (c *Client) Close() error {
|
||||
return c.conn.Close()
|
||||
}
|
||||
|
||||
func (c *Client) Search(ctx context.Context, query string, limit uint32) ([]string, error) {
|
||||
resp, err := c.grpc.Search(ctx, &searchv1.SearchRequest{Query: query, Limit: limit})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return resp.GetRecordIds(), nil
|
||||
}
|
||||
Reference in New Issue
Block a user