Files
jcoffey-dev 13cf9a30cb Rebrand: Sentry -> Cairn OBS
Full rebrand across cosmetic branding, code identifiers, and
infrastructure/data-plane naming, using the supplied Cairn OBS logo
package. Cosmetic: favicon/logo swap (also closes a stale license-audit
finding -- the old favicon was SvelteKit's unreplaced scaffold logo),
new centered welcome landing page, larger/legible sidebar logo, page
titles, CLAUDE.md/README/docs prose.

Code identifiers: Go module path github.com/sentry/sentry ->
github.com/cairnobs/cairnobs across all 13 modules and ~91 files (protoc
regenerated); Rust crates sentry-agent/sentry-parser/sentry-search ->
cairnobs-*; CLI sentryctl -> cairnobsctl; Terraform provider fully
renamed (sentry_dashboard etc. -> cairnobs_dashboard, provider type,
env vars); every session/auth cookie name; agent config paths and
Windows service identity.

Deliberately preserved: the gRPC wire protocol's protobuf packages
(sentry.logs.v1, sentry.agent.v1) and their Go import directory
(proto/sentry/...) -- renaming the wire-level package would break every
currently-deployed agent binary (confirmed two real hosts, including
mail.inbuxa.com, are actively streaming through this exact contract)
until rebuilt and redeployed in lockstep with an ingest cutover. Only
the Go module path wrapping the generated code changes.

Infrastructure: every docker-compose container name (root and three
component-level compose files); the Helm chart (directory, Chart.yaml,
named-template helpers, all templates, values.yaml image repos);
Kubernetes Operator (CRD group sentry.io -> cairnobs.io, both CRD YAML
files, Go identifiers, RBAC markers); the coupled enterprise/tenantcrd
package. Caught and fixed real path-coupling bugs along the way: the
Helm chart's search/ingest volume mounts and the dev-only-credential
detection constant vs. docker-compose.yml's literal values had to move
together or a security warning would have silently stopped firing.

Data plane: Postgres database sentry_metadata -> cairnobs_metadata and
role sentry -> cairnobs; ClickHouse database sentry -> cairnobs; Kafka
topic sentry.logs.raw -> cairnobs.logs.raw and its consumer groups.
Source-level defaults, docker-compose.yml, and every migrate.sh/
provision script default updated together; already-applied migration
files left untouched per this repo's immutable-migration convention.

Verified at every layer: all 13 Go modules build/vet/test clean, both
Rust workspaces (agent, search) build/clippy/test clean, npm run check/
build clean, docker compose config validates on all four compose files.
Live-verified against a real docker stack multiple times through this
work, including a final fresh-volume run confirming the actual renamed
Postgres database/role, ClickHouse database, and Kafka topic all work
end to end with a real login and query, zero console errors.
2026-08-21 20:53:32 -07:00

713 lines
27 KiB
Go

package dashboards
import (
"context"
"encoding/json"
"errors"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/cairnobs/cairnobs/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")
}
}