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:
@@ -0,0 +1,316 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user