Phase 4: SSO scaffolding, RBAC enforcement, tenant-scoped dashboards, audit logging, K8s deployment

RBAC (api/internal/authz) is live on /query and /dashboards, backed by a
new enterprise/ module (session issuance, audit logging, RBAC storage,
OIDC/SAML protocol wiring) that core never imports -- only calls over
HTTP. Found and fixed a real cross-tenant vulnerability in dashboards
(no tenant_id filtering at all) while writing the threat model doc.

Two things are explicitly NOT done, documented rather than hidden:
tenant isolation for log data itself (/query still shares one ClickHouse
connection and Tantivy index across every tenant -- RBAC controls who
can query, not what a query can see), and human SSO login (protocol
wiring exists, no HTTP handler calls it yet). See
docs/security/threat-model.md and docs/phase-4-runbook.md.

Also adds deploy/ (Go Operator + Helm chart, validated offline only --
no cluster was reachable in this environment).
This commit is contained in:
2026-08-13 22:16:59 -07:00
parent 9435115ab7
commit 3eb0f4c589
116 changed files with 8589 additions and 126 deletions
+67 -33
View File
@@ -6,47 +6,80 @@ import (
"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.
// 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) ([]Dashboard, error)
GetDashboard(ctx context.Context, id string) (*Dashboard, error)
UpdateDashboard(ctx context.Context, d *Dashboard) error
DeleteDashboard(ctx context.Context, id string) error
AddPanel(ctx context.Context, dashboardID string, p *Panel) error
UpdatePanel(ctx context.Context, p *Panel) error
DeletePanel(ctx context.Context, dashboardID, panelID string) error
ImportDashboard(ctx context.Context, d *Dashboard) (*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
logger *slog.Logger
store store
authorizer authz.Authorizer
}
func NewHandler(logger *slog.Logger, store store) *Handler {
return &Handler{logger: logger, store: store}
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", h.handleCreate)
mux.HandleFunc("GET /dashboards", h.handleList)
mux.HandleFunc("POST /dashboards/import", h.handleImport)
mux.HandleFunc("GET /dashboards/{id}", h.handleGet)
mux.HandleFunc("PUT /dashboards/{id}", h.handleUpdate)
mux.HandleFunc("DELETE /dashboards/{id}", h.handleDelete)
mux.HandleFunc("GET /dashboards/{id}/export", h.handleExport)
mux.HandleFunc("POST /dashboards/{id}/panels", h.handleAddPanel)
mux.HandleFunc("PUT /dashboards/{id}/panels/{panelId}", h.handleUpdatePanel)
mux.HandleFunc("DELETE /dashboards/{id}/panels/{panelId}", h.handleDeletePanel)
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) {
@@ -56,6 +89,8 @@ func (h *Handler) handleCreate(w http.ResponseWriter, r *http.Request) {
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")
@@ -65,7 +100,7 @@ func (h *Handler) handleCreate(w http.ResponseWriter, r *http.Request) {
}
func (h *Handler) handleList(w http.ResponseWriter, r *http.Request) {
list, err := h.store.ListDashboards(r.Context())
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")
@@ -75,7 +110,7 @@ func (h *Handler) handleList(w http.ResponseWriter, r *http.Request) {
}
func (h *Handler) handleGet(w http.ResponseWriter, r *http.Request) {
d, err := h.store.GetDashboard(r.Context(), r.PathValue("id"))
d, err := h.store.GetDashboard(r.Context(), h.tenantID(r), r.PathValue("id"))
if err != nil {
h.writeStoreErr(w, err, "fetching dashboard")
return
@@ -100,7 +135,7 @@ func (h *Handler) handleImport(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusBadRequest, "name must not be empty")
return
}
imported, err := h.store.ImportDashboard(r.Context(), &d)
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())
@@ -119,7 +154,7 @@ func (h *Handler) handleUpdate(w http.ResponseWriter, r *http.Request) {
return
}
d.ID = r.PathValue("id")
if err := h.store.UpdateDashboard(r.Context(), &d); err != nil {
if err := h.store.UpdateDashboard(r.Context(), h.tenantID(r), &d); err != nil {
h.writeStoreErr(w, err, "updating dashboard")
return
}
@@ -127,7 +162,7 @@ func (h *Handler) handleUpdate(w http.ResponseWriter, r *http.Request) {
}
func (h *Handler) handleDelete(w http.ResponseWriter, r *http.Request) {
if err := h.store.DeleteDashboard(r.Context(), r.PathValue("id")); err != nil {
if err := h.store.DeleteDashboard(r.Context(), h.tenantID(r), r.PathValue("id")); err != nil {
h.writeStoreErr(w, err, "deleting dashboard")
return
}
@@ -143,9 +178,8 @@ func (h *Handler) handleAddPanel(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusBadRequest, err.Error())
return
}
if err := h.store.AddPanel(r.Context(), r.PathValue("id"), &p); err != nil {
h.logger.Error("adding panel", "error", err)
writeError(w, http.StatusInternalServerError, "adding panel failed")
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)
@@ -162,7 +196,7 @@ func (h *Handler) handleUpdatePanel(w http.ResponseWriter, r *http.Request) {
}
p.ID = r.PathValue("panelId")
p.DashboardID = r.PathValue("id")
if err := h.store.UpdatePanel(r.Context(), &p); err != nil {
if err := h.store.UpdatePanel(r.Context(), h.tenantID(r), &p); err != nil {
h.writeStoreErr(w, err, "updating panel")
return
}
@@ -170,7 +204,7 @@ func (h *Handler) handleUpdatePanel(w http.ResponseWriter, r *http.Request) {
}
func (h *Handler) handleDeletePanel(w http.ResponseWriter, r *http.Request) {
if err := h.store.DeletePanel(r.Context(), r.PathValue("id"), r.PathValue("panelId")); err != nil {
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
}
+207 -22
View File
@@ -10,8 +10,15 @@ import (
"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
@@ -31,44 +38,48 @@ func (f *fakeStore) CreateDashboard(_ context.Context, d *Dashboard) error {
return nil
}
func (f *fakeStore) ListDashboards(_ context.Context) ([]Dashboard, error) {
func (f *fakeStore) ListDashboards(_ context.Context, tenantID string) ([]Dashboard, error) {
var out []Dashboard
for _, d := range f.dashboards {
out = append(out, *d)
if d.TenantID == tenantID {
out = append(out, *d)
}
}
return out, nil
}
func (f *fakeStore) GetDashboard(_ context.Context, id string) (*Dashboard, error) {
func (f *fakeStore) GetDashboard(_ context.Context, tenantID, id string) (*Dashboard, error) {
d, ok := f.dashboards[id]
if !ok {
if !ok || d.TenantID != tenantID {
return nil, ErrNotFound
}
return d, nil
}
func (f *fakeStore) UpdateDashboard(_ context.Context, d *Dashboard) error {
func (f *fakeStore) UpdateDashboard(_ context.Context, tenantID string, d *Dashboard) error {
existing, ok := f.dashboards[d.ID]
if !ok {
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, id string) error {
if _, ok := f.dashboards[id]; !ok {
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, dashboardID string, p *Panel) error {
func (f *fakeStore) AddPanel(_ context.Context, tenantID, dashboardID string, p *Panel) error {
d, ok := f.dashboards[dashboardID]
if !ok {
if !ok || d.TenantID != tenantID {
return ErrNotFound
}
p.ID = "panel-1"
@@ -77,9 +88,9 @@ func (f *fakeStore) AddPanel(_ context.Context, dashboardID string, p *Panel) er
return nil
}
func (f *fakeStore) UpdatePanel(_ context.Context, p *Panel) error {
func (f *fakeStore) UpdatePanel(_ context.Context, tenantID string, p *Panel) error {
d, ok := f.dashboards[p.DashboardID]
if !ok {
if !ok || d.TenantID != tenantID {
return ErrNotFound
}
for i := range d.Panels {
@@ -91,9 +102,9 @@ func (f *fakeStore) UpdatePanel(_ context.Context, p *Panel) error {
return ErrNotFound
}
func (f *fakeStore) DeletePanel(_ context.Context, dashboardID, panelID string) error {
func (f *fakeStore) DeletePanel(_ context.Context, tenantID, dashboardID, panelID string) error {
d, ok := f.dashboards[dashboardID]
if !ok {
if !ok || d.TenantID != tenantID {
return ErrNotFound
}
for i := range d.Panels {
@@ -105,18 +116,39 @@ func (f *fakeStore) DeletePanel(_ context.Context, dashboardID, panelID string)
return ErrNotFound
}
func (f *fakeStore) ImportDashboard(_ context.Context, d *Dashboard) (*Dashboard, error) {
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)
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
@@ -149,6 +181,25 @@ func TestCreateDashboard(t *testing.T) {
}
}
// 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": ""}`)
@@ -165,9 +216,87 @@ func TestGetDashboardNotFound(t *testing.T) {
}
}
// 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", Name: "Overview"}
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",
@@ -179,7 +308,7 @@ func TestAddPanelRejectsRawSQL(t *testing.T) {
func TestAddPanelRejectsInvalidVizType(t *testing.T) {
fs := newFakeStore()
fs.dashboards["dash-1"] = &Dashboard{ID: "dash-1", Name: "Overview"}
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",
@@ -191,7 +320,7 @@ func TestAddPanelRejectsInvalidVizType(t *testing.T) {
func TestAddPanelSuccess(t *testing.T) {
fs := newFakeStore()
fs.dashboards["dash-1"] = &Dashboard{ID: "dash-1", Name: "Overview"}
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",
@@ -206,7 +335,7 @@ func TestAddPanelSuccess(t *testing.T) {
func TestUpdateDashboardChangesTimeRange(t *testing.T) {
fs := newFakeStore()
fs.dashboards["dash-1"] = &Dashboard{ID: "dash-1", Name: "Overview", DefaultEarliest: "-1h", DefaultLatest: "now"}
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",
@@ -229,7 +358,7 @@ func TestUpdateDashboardNotFound(t *testing.T) {
func TestDeleteDashboard(t *testing.T) {
fs := newFakeStore()
fs.dashboards["dash-1"] = &Dashboard{ID: "dash-1", Name: "Overview"}
fs.dashboards["dash-1"] = &Dashboard{ID: "dash-1", TenantID: "default", Name: "Overview"}
mux := newTestMux(fs)
rec := doRequest(t, mux, http.MethodDelete, "/dashboards/dash-1", "")
@@ -244,7 +373,7 @@ func TestDeleteDashboard(t *testing.T) {
func TestExportThenImportRoundTrips(t *testing.T) {
fs := newFakeStore()
fs.dashboards["dash-1"] = &Dashboard{
ID: "dash-1", Name: "Overview",
ID: "dash-1", TenantID: "default", Name: "Overview",
Panels: []Panel{{ID: "panel-1", DashboardID: "dash-1", Query: "service=api", VizType: VizTable}},
}
mux := newTestMux(fs)
@@ -267,6 +396,28 @@ func TestExportThenImportRoundTrips(t *testing.T) {
}
}
// 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")
@@ -277,3 +428,37 @@ func TestCreateDashboardStoreErrorReturns500(t *testing.T) {
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)
}
}
}
+79 -19
View File
@@ -10,13 +10,30 @@ import (
"github.com/jackc/pgx/v5/pgxpool"
)
// ErrNotFound is returned by Get/Delete when the id doesn't exist.
// 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
}
@@ -25,6 +42,10 @@ 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 == "" {
@@ -47,10 +68,10 @@ func (s *Store) CreateDashboard(ctx context.Context, d *Dashboard) error {
return row.Scan(&d.CreatedAt, &d.UpdatedAt)
}
func (s *Store) ListDashboards(ctx context.Context) ([]Dashboard, error) {
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 ORDER BY created_at DESC`)
FROM dashboards WHERE tenant_id = $1 ORDER BY created_at DESC`, tenantID)
if err != nil {
return nil, err
}
@@ -67,11 +88,11 @@ func (s *Store) ListDashboards(ctx context.Context) ([]Dashboard, error) {
return out, rows.Err()
}
func (s *Store) GetDashboard(ctx context.Context, id string) (*Dashboard, error) {
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`, id)
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
@@ -87,6 +108,10 @@ func (s *Store) GetDashboard(ctx context.Context, id string) (*Dashboard, error)
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,
@@ -111,7 +136,21 @@ func (s *Store) listPanels(ctx context.Context, dashboardID string) ([]Panel, er
return out, rows.Err()
}
func (s *Store) UpdateDashboard(ctx context.Context, d *Dashboard) error {
// 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"
}
@@ -120,9 +159,9 @@ func (s *Store) UpdateDashboard(ctx context.Context, d *Dashboard) error {
}
row := s.pool.QueryRow(ctx, `
UPDATE dashboards SET name = $1, description = $2, default_earliest = $3, default_latest = $4, updated_at = now()
WHERE id = $5
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)
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
@@ -132,8 +171,8 @@ func (s *Store) UpdateDashboard(ctx context.Context, d *Dashboard) error {
return nil
}
func (s *Store) DeleteDashboard(ctx context.Context, id string) error {
tag, err := s.pool.Exec(ctx, `DELETE FROM dashboards WHERE id = $1`, id)
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
}
@@ -143,7 +182,14 @@ func (s *Store) DeleteDashboard(ctx context.Context, id string) error {
return nil
}
func (s *Store) AddPanel(ctx context.Context, dashboardID string, p *Panel) error {
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, `
@@ -156,7 +202,14 @@ func (s *Store) AddPanel(ctx context.Context, dashboardID string, p *Panel) erro
return row.Scan(&p.CreatedAt, &p.UpdatedAt)
}
func (s *Store) UpdatePanel(ctx context.Context, p *Panel) error {
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,
@@ -175,7 +228,14 @@ func (s *Store) UpdatePanel(ctx context.Context, p *Panel) error {
return nil
}
func (s *Store) DeletePanel(ctx context.Context, dashboardID, panelID string) error {
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
@@ -191,8 +251,12 @@ func (s *Store) DeletePanel(ctx context.Context, dashboardID, panelID string) er
// 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.
func (s *Store) ImportDashboard(ctx context.Context, d *Dashboard) (*Dashboard, error) {
// 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
@@ -200,10 +264,6 @@ func (s *Store) ImportDashboard(ctx context.Context, d *Dashboard) (*Dashboard,
defer tx.Rollback(ctx)
id := uuid.NewString()
tenantID := d.TenantID
if tenantID == "" {
tenantID = "default"
}
createdBy := d.CreatedBy
if createdBy == "" {
createdBy = "anonymous"
@@ -0,0 +1,192 @@
// 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")
}
}