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).
713 lines
27 KiB
Go
713 lines
27 KiB
Go
package dashboards
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"io"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/sentry/sentry/api/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, 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
|
|
}
|
|
|
|
// fakePermissionStore is an in-memory PermissionStore, keyed by
|
|
// dashboardID -> userID -> grant, enough to drive canEditDashboard/
|
|
// canManageGrants's "granted" path and the permission-management
|
|
// handlers without a real Postgres.
|
|
type fakePermissionStore struct {
|
|
grants map[string]map[string]Permission
|
|
}
|
|
|
|
func newFakePermissionStore() *fakePermissionStore {
|
|
return &fakePermissionStore{grants: map[string]map[string]Permission{}}
|
|
}
|
|
|
|
func (f *fakePermissionStore) GrantedRole(_ context.Context, dashboardID, userID string) (authz.Role, bool, error) {
|
|
p, ok := f.grants[dashboardID][userID]
|
|
if !ok {
|
|
return "", false, nil
|
|
}
|
|
return p.Role, true, nil
|
|
}
|
|
|
|
func (f *fakePermissionStore) SetPermission(_ context.Context, dashboardID, userID string, role authz.Role, grantedBy string) error {
|
|
if f.grants[dashboardID] == nil {
|
|
f.grants[dashboardID] = map[string]Permission{}
|
|
}
|
|
f.grants[dashboardID][userID] = Permission{UserID: userID, Role: role, GrantedBy: grantedBy}
|
|
return nil
|
|
}
|
|
|
|
func (f *fakePermissionStore) RevokePermission(_ context.Context, dashboardID, userID string) error {
|
|
delete(f.grants[dashboardID], userID)
|
|
return nil
|
|
}
|
|
|
|
func (f *fakePermissionStore) ListPermissions(_ context.Context, dashboardID string) ([]Permission, error) {
|
|
var out []Permission
|
|
for _, p := range f.grants[dashboardID] {
|
|
out = append(out, p)
|
|
}
|
|
return out, 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}}, nil)
|
|
mux := http.NewServeMux()
|
|
h.RegisterRoutes(mux)
|
|
return mux
|
|
}
|
|
|
|
// newTestMuxWithIdentity is newTestMuxWithTenant's more general sibling
|
|
// -- lets ownership/grant tests control Role and UserID directly (Owner
|
|
// always bypasses canEditDashboard/canManageGrants, so those tests can't
|
|
// use newTestMuxWithTenant), and optionally wires a PermissionStore.
|
|
func newTestMuxWithIdentity(fs *fakeStore, identity authz.Identity, permissions PermissionStore) *http.ServeMux {
|
|
h := NewHandler(slog.New(slog.NewTextHandler(io.Discard, nil)), fs, &fakeAuthorizer{identity: identity}, permissions)
|
|
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/queryapi's own tests) -- api/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}}, nil)
|
|
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)
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- "(own/granted)" qualifier tests (Phase 4 task 5) ---
|
|
|
|
func TestEditorCannotEditOthersDashboardWithoutGrant(t *testing.T) {
|
|
fs := newFakeStore()
|
|
fs.dashboards["dash-1"] = &Dashboard{ID: "dash-1", TenantID: "acme", Name: "Overview", CreatedBy: "user-owner"}
|
|
mux := newTestMuxWithIdentity(fs, authz.Identity{TenantID: "acme", UserID: "user-editor", Role: authz.RoleEditor}, newFakePermissionStore())
|
|
|
|
rec := doRequest(t, mux, http.MethodPut, "/dashboards/dash-1", `{"name": "Hijacked"}`)
|
|
if rec.Code != http.StatusForbidden {
|
|
t.Fatalf("status = %d, want 403; body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
if fs.dashboards["dash-1"].Name != "Overview" {
|
|
t.Fatalf("must not modify the dashboard, got Name = %q", fs.dashboards["dash-1"].Name)
|
|
}
|
|
}
|
|
|
|
func TestEditorCanEditOwnDashboard(t *testing.T) {
|
|
fs := newFakeStore()
|
|
fs.dashboards["dash-1"] = &Dashboard{ID: "dash-1", TenantID: "acme", Name: "Overview", CreatedBy: "user-editor"}
|
|
mux := newTestMuxWithIdentity(fs, authz.Identity{TenantID: "acme", UserID: "user-editor", Role: authz.RoleEditor}, newFakePermissionStore())
|
|
|
|
rec := doRequest(t, mux, http.MethodPut, "/dashboards/dash-1", `{"name": "Updated"}`)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestEditorCanEditDashboardGrantedEditorRole(t *testing.T) {
|
|
fs := newFakeStore()
|
|
fs.dashboards["dash-1"] = &Dashboard{ID: "dash-1", TenantID: "acme", Name: "Overview", CreatedBy: "user-owner"}
|
|
perms := newFakePermissionStore()
|
|
if err := perms.SetPermission(context.Background(), "dash-1", "user-editor", authz.RoleEditor, "user-owner"); err != nil {
|
|
t.Fatalf("seeding grant: %v", err)
|
|
}
|
|
mux := newTestMuxWithIdentity(fs, authz.Identity{TenantID: "acme", UserID: "user-editor", Role: authz.RoleEditor}, perms)
|
|
|
|
rec := doRequest(t, mux, http.MethodPut, "/dashboards/dash-1", `{"name": "Updated"}`)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, want 200 (a granted Editor role must allow editing someone else's dashboard); body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestEditorCannotEditDashboardGrantedViewerRoleOnly(t *testing.T) {
|
|
fs := newFakeStore()
|
|
fs.dashboards["dash-1"] = &Dashboard{ID: "dash-1", TenantID: "acme", Name: "Overview", CreatedBy: "user-owner"}
|
|
perms := newFakePermissionStore()
|
|
if err := perms.SetPermission(context.Background(), "dash-1", "user-editor", authz.RoleViewer, "user-owner"); err != nil {
|
|
t.Fatalf("seeding grant: %v", err)
|
|
}
|
|
mux := newTestMuxWithIdentity(fs, authz.Identity{TenantID: "acme", UserID: "user-editor", Role: authz.RoleEditor}, perms)
|
|
|
|
rec := doRequest(t, mux, http.MethodPut, "/dashboards/dash-1", `{"name": "Updated"}`)
|
|
if rec.Code != http.StatusForbidden {
|
|
t.Fatalf("status = %d, want 403 (a Viewer-level grant must not allow editing -- additive-only, not an escalation)", rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestAdminCanEditAnyDashboardWithoutGrant(t *testing.T) {
|
|
fs := newFakeStore()
|
|
fs.dashboards["dash-1"] = &Dashboard{ID: "dash-1", TenantID: "acme", Name: "Overview", CreatedBy: "user-owner"}
|
|
mux := newTestMuxWithIdentity(fs, authz.Identity{TenantID: "acme", UserID: "user-admin", Role: authz.RoleAdmin}, newFakePermissionStore())
|
|
|
|
rec := doRequest(t, mux, http.MethodDelete, "/dashboards/dash-1", "")
|
|
if rec.Code != http.StatusNoContent {
|
|
t.Fatalf("status = %d, want 204 (Admin may act on any dashboard in its tenant); body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestEditorCannotEditPanelsOfOthersDashboardWithoutGrant(t *testing.T) {
|
|
fs := newFakeStore()
|
|
fs.dashboards["dash-1"] = &Dashboard{ID: "dash-1", TenantID: "acme", Name: "Overview", CreatedBy: "user-owner"}
|
|
mux := newTestMuxWithIdentity(fs, authz.Identity{TenantID: "acme", UserID: "user-editor", Role: authz.RoleEditor}, newFakePermissionStore())
|
|
|
|
rec := doRequest(t, mux, http.MethodPost, "/dashboards/dash-1/panels", `{"query": "service=api", "viz_type": "table"}`)
|
|
if rec.Code != http.StatusForbidden {
|
|
t.Fatalf("status = %d, want 403; body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
if len(fs.dashboards["dash-1"].Panels) != 0 {
|
|
t.Fatalf("must not attach a panel without edit access")
|
|
}
|
|
}
|
|
|
|
func TestNoPermissionStoreFallsBackToOwnershipOnly(t *testing.T) {
|
|
fs := newFakeStore()
|
|
fs.dashboards["dash-1"] = &Dashboard{ID: "dash-1", TenantID: "acme", Name: "Overview", CreatedBy: "user-owner"}
|
|
// authorizer is real (RBAC enforced) but permissions is nil -- e.g.
|
|
// plain api/cmd/api with ENTERPRISE_AUTH_URL set but no enterprise
|
|
// permission service wired. Own/Admin must still work; "granted"
|
|
// simply isn't checkable.
|
|
mux := newTestMuxWithIdentity(fs, authz.Identity{TenantID: "acme", UserID: "user-editor", Role: authz.RoleEditor}, nil)
|
|
|
|
rec := doRequest(t, mux, http.MethodPut, "/dashboards/dash-1", `{"name": "Updated"}`)
|
|
if rec.Code != http.StatusForbidden {
|
|
t.Fatalf("status = %d, want 403 (no permission store means no 'granted' bonus, and this user isn't the creator)", rec.Code)
|
|
}
|
|
}
|
|
|
|
// --- grant management endpoint tests ---
|
|
|
|
func TestCreatorCanManageGrants(t *testing.T) {
|
|
fs := newFakeStore()
|
|
fs.dashboards["dash-1"] = &Dashboard{ID: "dash-1", TenantID: "acme", Name: "Overview", CreatedBy: "user-creator"}
|
|
perms := newFakePermissionStore()
|
|
mux := newTestMuxWithIdentity(fs, authz.Identity{TenantID: "acme", UserID: "user-creator", Role: authz.RoleEditor}, perms)
|
|
|
|
rec := doRequest(t, mux, http.MethodPut, "/dashboards/dash-1/permissions/user-2", `{"role": "editor"}`)
|
|
if rec.Code != http.StatusNoContent {
|
|
t.Fatalf("status = %d, want 204; body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
role, ok, err := perms.GrantedRole(context.Background(), "dash-1", "user-2")
|
|
if err != nil || !ok || role != authz.RoleEditor {
|
|
t.Fatalf("GrantedRole = (%v, %v, %v), want (editor, true, nil)", role, ok, err)
|
|
}
|
|
}
|
|
|
|
// TestGrantedEditorCannotManageGrants is the regression test for the
|
|
// matrix's distinction between "(own/granted)" (edit/delete content) and
|
|
// "(if creator)" (manage grants) -- a user who can edit only because of
|
|
// a grant must not be able to extend or re-grant that access to anyone,
|
|
// including themselves.
|
|
func TestGrantedEditorCannotManageGrants(t *testing.T) {
|
|
fs := newFakeStore()
|
|
fs.dashboards["dash-1"] = &Dashboard{ID: "dash-1", TenantID: "acme", Name: "Overview", CreatedBy: "user-owner"}
|
|
perms := newFakePermissionStore()
|
|
if err := perms.SetPermission(context.Background(), "dash-1", "user-editor", authz.RoleEditor, "user-owner"); err != nil {
|
|
t.Fatalf("seeding grant: %v", err)
|
|
}
|
|
mux := newTestMuxWithIdentity(fs, authz.Identity{TenantID: "acme", UserID: "user-editor", Role: authz.RoleEditor}, perms)
|
|
|
|
rec := doRequest(t, mux, http.MethodPut, "/dashboards/dash-1/permissions/user-3", `{"role": "editor"}`)
|
|
if rec.Code != http.StatusForbidden {
|
|
t.Fatalf("status = %d, want 403 (a granted Editor must not be able to grant access to others)", rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestSetPermissionRejectsInvalidRole(t *testing.T) {
|
|
fs := newFakeStore()
|
|
fs.dashboards["dash-1"] = &Dashboard{ID: "dash-1", TenantID: "acme", Name: "Overview", CreatedBy: "user-creator"}
|
|
mux := newTestMuxWithIdentity(fs, authz.Identity{TenantID: "acme", UserID: "user-creator", Role: authz.RoleEditor}, newFakePermissionStore())
|
|
|
|
rec := doRequest(t, mux, http.MethodPut, "/dashboards/dash-1/permissions/user-2", `{"role": "admin"}`)
|
|
if rec.Code != http.StatusBadRequest {
|
|
t.Fatalf("status = %d, want 400 (admin is not a grantable resource-level role -- see metadata/migrations/0033)", rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestRevokePermission(t *testing.T) {
|
|
fs := newFakeStore()
|
|
fs.dashboards["dash-1"] = &Dashboard{ID: "dash-1", TenantID: "acme", Name: "Overview", CreatedBy: "user-creator"}
|
|
perms := newFakePermissionStore()
|
|
if err := perms.SetPermission(context.Background(), "dash-1", "user-2", authz.RoleEditor, "user-creator"); err != nil {
|
|
t.Fatalf("seeding grant: %v", err)
|
|
}
|
|
mux := newTestMuxWithIdentity(fs, authz.Identity{TenantID: "acme", UserID: "user-creator", Role: authz.RoleEditor}, perms)
|
|
|
|
rec := doRequest(t, mux, http.MethodDelete, "/dashboards/dash-1/permissions/user-2", "")
|
|
if rec.Code != http.StatusNoContent {
|
|
t.Fatalf("status = %d, want 204; body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
if _, ok, _ := perms.GrantedRole(context.Background(), "dash-1", "user-2"); ok {
|
|
t.Fatalf("expected the grant to be revoked")
|
|
}
|
|
}
|
|
|
|
func TestPermissionEndpointsReturn501WithoutPermissionStore(t *testing.T) {
|
|
fs := newFakeStore()
|
|
fs.dashboards["dash-1"] = &Dashboard{ID: "dash-1", TenantID: "acme", Name: "Overview", CreatedBy: "user-creator"}
|
|
mux := newTestMuxWithIdentity(fs, authz.Identity{TenantID: "acme", UserID: "user-creator", Role: authz.RoleEditor}, nil)
|
|
|
|
rec := doRequest(t, mux, http.MethodPut, "/dashboards/dash-1/permissions/user-2", `{"role": "editor"}`)
|
|
if rec.Code != http.StatusNotImplemented {
|
|
t.Fatalf("status = %d, want 501 (no enterprise permission service wired)", rec.Code)
|
|
}
|
|
}
|
|
|
|
// TestImportStampsImportingUserAsCreator is the regression test for the
|
|
// created_by-on-import fix: without it, an imported dashboard would keep
|
|
// the *exported* JSON's created_by, leaving the actual importer unable
|
|
// to edit their own freshly-imported copy.
|
|
func TestImportStampsImportingUserAsCreator(t *testing.T) {
|
|
fs := newFakeStore()
|
|
mux := newTestMuxWithIdentity(fs, authz.Identity{TenantID: "acme", UserID: "user-importer", Role: authz.RoleEditor}, newFakePermissionStore())
|
|
|
|
rec := doRequest(t, mux, http.MethodPost, "/dashboards/import", `{"name": "Imported", "created_by": "someone-else"}`)
|
|
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.CreatedBy != "user-importer" {
|
|
t.Fatalf("CreatedBy = %q, want %q (the importing identity, not the exported JSON's value)", imported.CreatedBy, "user-importer")
|
|
}
|
|
}
|