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:
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user