mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-27 05:57:27 +00:00
3f511a5270
The report engine read every report's metrics from the default organization's store, whatever organization the request came from. A tenant request for a resource ID outside its own inventory fell through to that store with the caller's ID, so one organization could read the default organization's history, and even a tenant's own resources were reported from the wrong store. Reporting was closed to org owners until this was fixed. The engine now resolves the metrics store of each request's OrgID through the multi-tenant monitor and refuses a request that names none, so no report path can fall back to the default store. Report handlers reject resources outside the requesting organization's inventory, scheduled runs resolve their scope against their owning organization's inventory and skip resources it no longer holds, and Pulse Assistant's pulse_summarize reads its executor's organization. With every report read scoped, the reporting routes move to ensureOrgAdminSession and reportingRead is offered to org managers again, so providers can build reports inside client workspaces. The report engine wiring lives in pkg/server/server.go, which the completion guard only let telemetry tests prove. server.go now has its own security-privacy path policy that keeps those proofs and also accepts pkg/server/reporting_engine_test.go; the other telemetry files keep their existing policy unchanged.
177 lines
6.6 KiB
Go
177 lines
6.6 KiB
Go
package api
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/config"
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/models"
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/monitoring"
|
|
"github.com/rcourtman/pulse-go-rewrite/pkg/metrics"
|
|
"github.com/rcourtman/pulse-go-rewrite/pkg/reporting"
|
|
)
|
|
|
|
// Org A owns node-a and org B owns node-b. Org A's metrics store also holds
|
|
// points under node-b, so a report that reads the wrong organization's store
|
|
// shows org A's value (11) instead of org B's (77).
|
|
const (
|
|
orgScopeValueA = "11.00"
|
|
orgScopeValueB = "77.00"
|
|
)
|
|
|
|
func newReportingOrgScopeFixture(t *testing.T) *ReportingHandlers {
|
|
t.Helper()
|
|
now := time.Now()
|
|
stores := map[string]*metrics.Store{}
|
|
for orgID, series := range map[string]map[string]float64{
|
|
"org-a": {"node-a": 11, "node-b": 11},
|
|
"org-b": {"node-b": 77},
|
|
} {
|
|
store, err := metrics.NewStore(metrics.DefaultConfig(t.TempDir()))
|
|
if err != nil {
|
|
t.Fatalf("metrics store for %s: %v", orgID, err)
|
|
}
|
|
t.Cleanup(func() { store.Close() })
|
|
// Every tier holds the points, so whichever tier a report window
|
|
// selects finds them.
|
|
for resourceID, value := range series {
|
|
for _, tier := range []metrics.Tier{metrics.TierRaw, metrics.TierMinute, metrics.TierHourly, metrics.TierDaily} {
|
|
for i := 0; i < 6; i++ {
|
|
store.WriteWithTier("node", resourceID, "cpu", value, now.Add(time.Duration(-50+i*5)*time.Minute), tier)
|
|
}
|
|
}
|
|
}
|
|
store.Flush()
|
|
stores[orgID] = store
|
|
}
|
|
|
|
original := reporting.GetEngine()
|
|
reporting.SetEngine(reporting.NewReportEngine(reporting.EngineConfig{
|
|
MetricsStoreForOrg: func(orgID string) (*metrics.Store, error) {
|
|
store, ok := stores[orgID]
|
|
if !ok {
|
|
return nil, fmt.Errorf("organization %q is not provisioned", orgID)
|
|
}
|
|
return store, nil
|
|
},
|
|
}))
|
|
t.Cleanup(func() { reporting.SetEngine(original) })
|
|
|
|
stateA := models.NewState()
|
|
stateA.Nodes = []models.Node{{ID: "node-a", Name: "pve-a", Status: "online"}}
|
|
stateB := models.NewState()
|
|
stateB.Nodes = []models.Node{{ID: "node-b", Name: "pve-b", Status: "online"}}
|
|
|
|
mtm := monitoring.NewMultiTenantMonitor(&config.Config{}, nil, nil)
|
|
setUnexportedField(t, mtm, "monitors", map[string]*monitoring.Monitor{
|
|
"org-a": newReportingMonitorForTest(t, stateA, nil),
|
|
"org-b": newReportingMonitorForTest(t, stateB, nil),
|
|
})
|
|
return NewReportingHandlers(mtm, nil)
|
|
}
|
|
|
|
func orgScopeContext(orgID string) context.Context {
|
|
return context.WithValue(context.Background(), OrgIDContextKey, orgID)
|
|
}
|
|
|
|
func orgScopeUnifiedNodeID(t *testing.T, handlers *ReportingHandlers, orgID string) string {
|
|
t.Helper()
|
|
snapshot, ok := handlers.getReportingEnrichmentSnapshot(context.Background(), orgID)
|
|
if !ok {
|
|
t.Fatalf("no inventory snapshot for %s", orgID)
|
|
}
|
|
for _, resource := range snapshot.Resources {
|
|
if reporting.CanonicalResourceType(string(resource.Type)) != "" && resource.Name != "" {
|
|
return resource.ID
|
|
}
|
|
}
|
|
t.Fatalf("no unified resource in %s inventory: %+v", orgID, snapshot.Resources)
|
|
return ""
|
|
}
|
|
|
|
func TestReportingSingleReportReadsOnlyTheRequestOrganizationsMetrics(t *testing.T) {
|
|
handlers := newReportingOrgScopeFixture(t)
|
|
unifiedA := orgScopeUnifiedNodeID(t, handlers, "org-a")
|
|
|
|
generate := func(orgID, method, resourceID string) *httptest.ResponseRecorder {
|
|
var req *http.Request
|
|
if method == http.MethodGet {
|
|
req = httptest.NewRequest(http.MethodGet, "/api/admin/reports/generate?format=csv&resourceType=node&metricType=cpu&resourceId="+resourceID, nil)
|
|
} else {
|
|
body := fmt.Sprintf(`{"format":"csv","resourceType":"node","metricType":"cpu","resourceId":%q}`, resourceID)
|
|
req = httptest.NewRequest(http.MethodPost, "/api/admin/reports/generate", strings.NewReader(body))
|
|
}
|
|
rec := httptest.NewRecorder()
|
|
handlers.HandleGenerateReport(rec, req.WithContext(orgScopeContext(orgID)))
|
|
return rec
|
|
}
|
|
|
|
for _, method := range []string{http.MethodGet, http.MethodPost} {
|
|
// Org A's resource, by legacy and unified ID, is not reportable from org B.
|
|
for _, foreignID := range []string{"node-a", unifiedA} {
|
|
rec := generate("org-b", method, foreignID)
|
|
if rec.Code != http.StatusNotFound || strings.Contains(rec.Body.String(), orgScopeValueA) {
|
|
t.Fatalf("%s org B report on org A's %s = %d %q, want 404 without org A's metrics", method, foreignID, rec.Code, rec.Body.String())
|
|
}
|
|
}
|
|
|
|
// Org B's own resource reads org B's store, although org A's store
|
|
// holds points under the same ID.
|
|
rec := generate("org-b", method, "node-b")
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("%s org B report on node-b = %d %q", method, rec.Code, rec.Body.String())
|
|
}
|
|
if body := rec.Body.String(); !strings.Contains(body, orgScopeValueB) || strings.Contains(body, orgScopeValueA) {
|
|
t.Fatalf("%s org B report on node-b must carry only org B's metrics:\n%s", method, body)
|
|
}
|
|
|
|
// Org A still reports on its own resource.
|
|
rec = generate("org-a", method, "node-a")
|
|
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), orgScopeValueA) {
|
|
t.Fatalf("%s org A report on node-a = %d %q", method, rec.Code, rec.Body.String())
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestReportingMultiReportReadsOnlyTheRequestOrganizationsMetrics(t *testing.T) {
|
|
handlers := newReportingOrgScopeFixture(t)
|
|
unifiedA := orgScopeUnifiedNodeID(t, handlers, "org-a")
|
|
|
|
generate := func(orgID string, resourceIDs ...string) *httptest.ResponseRecorder {
|
|
resources := make([]multiReportResourceEntry, 0, len(resourceIDs))
|
|
for _, id := range resourceIDs {
|
|
resources = append(resources, multiReportResourceEntry{ResourceType: "node", ResourceID: id})
|
|
}
|
|
body, err := json.Marshal(multiReportRequestBody{Resources: resources, Format: "csv", MetricType: "cpu"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
req := httptest.NewRequest(http.MethodPost, "/api/admin/reports/generate-multi", bytes.NewReader(body))
|
|
rec := httptest.NewRecorder()
|
|
handlers.HandleGenerateMultiReport(rec, req.WithContext(orgScopeContext(orgID)))
|
|
return rec
|
|
}
|
|
|
|
for _, foreignID := range []string{"node-a", unifiedA} {
|
|
rec := generate("org-b", "node-b", foreignID)
|
|
if rec.Code != http.StatusNotFound || strings.Contains(rec.Body.String(), orgScopeValueA) {
|
|
t.Fatalf("org B fleet report including org A's %s = %d %q, want 404 without org A's metrics", foreignID, rec.Code, rec.Body.String())
|
|
}
|
|
}
|
|
|
|
rec := generate("org-b", "node-b")
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("org B fleet report = %d %q", rec.Code, rec.Body.String())
|
|
}
|
|
if body := rec.Body.String(); !strings.Contains(body, orgScopeValueB) || strings.Contains(body, orgScopeValueA) {
|
|
t.Fatalf("org B fleet report must carry only org B's metrics:\n%s", body)
|
|
}
|
|
}
|