api/dashboards' handler previously enforced only tenant-baseline role
(RoleEditor+), so any Editor could edit/delete any dashboard in their
tenant -- the matrix's "(own/granted)" qualifier was explicitly named
as unbuilt in this handler's own doc comment. This closes that gap.
New core interface api/dashboards.PermissionStore (nil-safe, same "not
wired == no-op" shape as authz.Authorizer) resolves a per-resource
dashboard_permissions grant. canEditDashboard now requires the
identity be Admin/Owner, the dashboard's creator, or hold a grant of at
least Editor; canManageGrants is deliberately stricter (creator or
Admin/Owner only, never grant-derived access) so a user who can edit a
dashboard only because of a grant can't extend or re-grant that access
to themselves or others. Wired handlers: PUT/DELETE
/dashboards/{id}/permissions/{userId}, GET .../permissions.
Two real bugs found and fixed while wiring this up, before any of it
touched a live database:
- handleCreate/handleImport never stamped created_by from the
authenticated identity, so every dashboard was owned by "anonymous"
regardless of who made it -- the ownership check would have been
meaningless. Also fixed: ImportDashboard trusted the exported JSON's
created_by verbatim, so re-importing someone else's export would
leave the actual importer unable to edit their own copy.
- metadata/migrations/0024_create_dashboard_permissions.sql's CHECK
constraint diverged from /docs/phase-4-rbac-design.md's schema
(allowed role='admin', nullable granted_by). Reconciled via
0033_restrict_dashboard_permissions_role.sql: Admin/Owner already
have tenant-wide access so a resource-level "admin" grant is
meaningless, and every real grant now always has an attributable
granter.
enterprise/internal/rbacstore gets the storage side: raw CRUD
(dashboard_permissions.go) plus DashboardPermissions
(dashboards_adapter.go), an adapter implementing
api/dashboards.PermissionStore -- same pattern as audit.QueryAPILogger
over queryapi.AuditLogger. Wired into enterprise/cmd/enterprise-api
only; plain api/cmd/api passes nil (ownership/Admin checks still work
via the nil-permissions fallback, just without the "granted" bonus).
Verified: the full own/granted/admin/creator matrix, including the
granted-editor-cannot-manage-grants regression, passes against a fake
PermissionStore (api/dashboards/handler_test.go, all existing tests
also still pass unmodified in behavior). Real integration tests exist
in enterprise/internal/rbacstore/rbacstore_test.go (skip-gated on
RBACSTORE_TEST_POSTGRES_ADDR, same convention as every other
Postgres-backed piece this phase) but have not run against a live
database in this environment -- disclosed in threat-model.md,
phase-4-runbook.md, and enterprise/README.md alongside every other
piece carrying the same gap. Also fixed a stale path in
phase-4-runbook.md's dashboards-tenant-scoping section
(./internal/dashboards/... -> ./dashboards/..., stale since that
package moved out of api/internal/ earlier in this phase).
166 lines
5.2 KiB
Go
166 lines
5.2 KiB
Go
// Command api is Sentry's query API: a single POST /query endpoint
|
|
// accepting either the pipe syntax or raw SQL, compiled and routed
|
|
// across ClickHouse and search by internal/querylang. See
|
|
// queryapi and /docs/query-language-design.md for why this is
|
|
// plain REST rather than the pinned gRPC+gateway pattern.
|
|
package main
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"log/slog"
|
|
"net/http"
|
|
"os"
|
|
"os/signal"
|
|
"strings"
|
|
"syscall"
|
|
"time"
|
|
|
|
"github.com/ClickHouse/clickhouse-go/v2"
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
|
|
"github.com/sentry/sentry/api/authz"
|
|
"github.com/sentry/sentry/api/dashboards"
|
|
"github.com/sentry/sentry/api/httpserver"
|
|
"github.com/sentry/sentry/api/internal/config"
|
|
"github.com/sentry/sentry/api/queryapi"
|
|
"github.com/sentry/sentry/api/querylang/executor"
|
|
"github.com/sentry/sentry/api/searchclient"
|
|
)
|
|
|
|
func main() {
|
|
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
|
|
|
|
cfg, err := config.Load()
|
|
if err != nil {
|
|
logger.Error("loading config", "error", err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
// -healthcheck: a self-check mode for Docker's HEALTHCHECK, not a
|
|
// flag anyone runs by hand. The api image is distroless (no shell,
|
|
// no wget/curl -- see api/Dockerfile), so docker-compose's
|
|
// healthcheck execs this binary against itself instead of an
|
|
// external tool. Exits before any ClickHouse/Postgres/search dial,
|
|
// since those aren't what "is the HTTP server up" is asking.
|
|
if len(os.Args) > 1 && os.Args[1] == "-healthcheck" {
|
|
os.Exit(runHealthcheck(cfg.HTTPListenAddr))
|
|
}
|
|
|
|
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
|
defer stop()
|
|
|
|
conn, err := clickhouse.Open(&clickhouse.Options{
|
|
Addr: []string{cfg.ClickHouse.Addr},
|
|
Auth: clickhouse.Auth{
|
|
Database: cfg.ClickHouse.Database,
|
|
Username: cfg.ClickHouse.Username,
|
|
Password: cfg.ClickHouse.Password,
|
|
},
|
|
})
|
|
if err != nil {
|
|
logger.Error("opening clickhouse connection", "error", err)
|
|
os.Exit(1)
|
|
}
|
|
defer conn.Close()
|
|
|
|
if err := conn.Ping(ctx); err != nil {
|
|
logger.Error("pinging clickhouse", "error", err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
search, err := searchclient.Dial(cfg.SearchGRPCAddr)
|
|
if err != nil {
|
|
logger.Error("dialing search service", "error", err)
|
|
os.Exit(1)
|
|
}
|
|
defer search.Close()
|
|
|
|
pgDSN := fmt.Sprintf("postgres://%s:%s@%s/%s", cfg.Postgres.Username, cfg.Postgres.Password, cfg.Postgres.Addr, cfg.Postgres.Database)
|
|
pgPool, err := pgxpool.New(ctx, pgDSN)
|
|
if err != nil {
|
|
logger.Error("opening postgres pool", "error", err)
|
|
os.Exit(1)
|
|
}
|
|
defer pgPool.Close()
|
|
|
|
if err := pgPool.Ping(ctx); err != nil {
|
|
logger.Error("pinging postgres", "error", err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
// authorizer is nil (RequireRole* becomes a no-op) unless
|
|
// ENTERPRISE_AUTH_URL is configured -- matches Phase 0-3 behavior
|
|
// for a single-tenant deployment with no enterprise/ deployed.
|
|
var authorizer authz.Authorizer
|
|
if cfg.EnterpriseAuthURL != "" {
|
|
authorizer = authz.NewHTTPAuthorizer(cfg.EnterpriseAuthURL)
|
|
}
|
|
|
|
sqlRunner := executor.NewChRunner(conn)
|
|
// audit logging is nil (a no-op) until Phase 4 task 5 wires in
|
|
// enterprise/internal/audit -- see queryapi.AuditLogger's doc comment.
|
|
queryHandler := queryapi.NewHandler(logger, sqlRunner, search, cfg.QueryTimeout, nil, authorizer)
|
|
// permissions is nil -- api/cmd/api is single-tenant/core; the
|
|
// enterprise-supplied dashboard_permissions store is only wired in
|
|
// by enterprise/cmd/enterprise-api (see dashboards.PermissionStore's
|
|
// doc comment). Ownership/Admin access still work via
|
|
// canEditDashboard's nil-permissions fallback -- only the "granted"
|
|
// half of the matrix's "(own/granted)" qualifier is unavailable here.
|
|
dashboardsHandler := dashboards.NewHandler(logger, dashboards.NewStore(pgPool), authorizer, nil)
|
|
|
|
// One shared mux, CORS applied once around the whole thing -- see
|
|
// httpserver's doc comment for why this changed from each
|
|
// handler wrapping itself individually.
|
|
mux := http.NewServeMux()
|
|
queryHandler.RegisterRoutes(mux)
|
|
dashboardsHandler.RegisterRoutes(mux)
|
|
|
|
srv := &http.Server{
|
|
Addr: cfg.HTTPListenAddr,
|
|
Handler: httpserver.WithCORS(mux, cfg.CORSAllowedOrigin),
|
|
}
|
|
|
|
errCh := make(chan error, 1)
|
|
go func() {
|
|
logger.Info("api listening", "addr", cfg.HTTPListenAddr)
|
|
errCh <- srv.ListenAndServe()
|
|
}()
|
|
|
|
select {
|
|
case <-ctx.Done():
|
|
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancel()
|
|
if err := srv.Shutdown(shutdownCtx); err != nil {
|
|
logger.Error("graceful shutdown failed", "error", err)
|
|
}
|
|
case err := <-errCh:
|
|
if err != nil && err != http.ErrServerClosed {
|
|
logger.Error("server exited with error", "error", err)
|
|
os.Exit(1)
|
|
}
|
|
}
|
|
}
|
|
|
|
// runHealthcheck GETs its own /healthz and returns an exit code, for
|
|
// Docker's HEALTHCHECK to exec directly (see the -healthcheck flag
|
|
// above). listenAddr is HTTP_LISTEN_ADDR-shaped (e.g. ":8080") --
|
|
// "localhost" replaces a bare host part since that's this same
|
|
// container reaching itself, not another service.
|
|
func runHealthcheck(listenAddr string) int {
|
|
addr := listenAddr
|
|
if strings.HasPrefix(addr, ":") {
|
|
addr = "localhost" + addr
|
|
}
|
|
client := http.Client{Timeout: 3 * time.Second}
|
|
resp, err := client.Get("http://" + addr + "/healthz")
|
|
if err != nil {
|
|
return 1
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusOK {
|
|
return 1
|
|
}
|
|
return 0
|
|
}
|