Files
pulse/pkg/reporting/engine_org_scope_test.go
T
rcourtman 3f511a5270 Scope report metrics to the requesting organization
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.
2026-10-17 13:59:44 +01:00

122 lines
3.8 KiB
Go

package reporting
import (
"context"
"errors"
"fmt"
"path/filepath"
"strings"
"testing"
"time"
"github.com/rcourtman/pulse-go-rewrite/pkg/metrics"
)
func newOrgScopeTestStore(t *testing.T, cpu float64, now time.Time) *metrics.Store {
t.Helper()
store, err := metrics.NewStore(metrics.StoreConfig{
DBPath: filepath.Join(t.TempDir(), "metrics.db"),
WriteBufferSize: 10,
FlushInterval: 100 * time.Millisecond,
RetentionRaw: 24 * time.Hour,
RetentionMinute: 7 * 24 * time.Hour,
RetentionHourly: 30 * 24 * time.Hour,
RetentionDaily: 90 * 24 * time.Hour,
})
if err != nil {
t.Fatalf("create metrics store: %v", err)
}
t.Cleanup(func() { store.Close() })
// Both organizations record the same resource ID, so only the store a
// query lands on decides which value comes back.
for i := 0; i < 6; i++ {
store.Write("node", "shared-node", "cpu", cpu, now.Add(time.Duration(-30+i*5)*time.Minute))
}
store.Flush()
return store
}
func newOrgScopedTestEngine(t *testing.T, now time.Time) *ReportEngine {
t.Helper()
stores := map[string]*metrics.Store{
"org-a": newOrgScopeTestStore(t, 11, now),
"org-b": newOrgScopeTestStore(t, 77, now),
}
return NewReportEngine(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
},
})
}
func orgScopeTestRequest(orgID string, now time.Time) MetricReportRequest {
return MetricReportRequest{
OrgID: orgID,
ResourceType: "node",
ResourceID: "shared-node",
MetricType: "cpu",
Start: now.Add(-time.Hour),
End: now.Add(time.Minute),
Format: FormatCSV,
}
}
func TestReportEngineRefusesRequestsWithoutAnOrganization(t *testing.T) {
now := time.Now()
engine := newOrgScopedTestEngine(t, now)
req := orgScopeTestRequest("", now)
if _, _, err := engine.Generate(req); !errors.Is(err, ErrReportOrgRequired) {
t.Fatalf("Generate without org: got %v, want ErrReportOrgRequired", err)
}
if _, err := engine.NarrativeFor(req); !errors.Is(err, ErrReportOrgRequired) {
t.Fatalf("NarrativeFor without org: got %v, want ErrReportOrgRequired", err)
}
if _, err := engine.MetricEvidenceFor(context.Background(), req); !errors.Is(err, ErrReportOrgRequired) {
t.Fatalf("MetricEvidenceFor without org: got %v, want ErrReportOrgRequired", err)
}
multi := MultiReportRequest{
Resources: []MetricReportRequest{orgScopeTestRequest("org-a", now)},
Start: req.Start,
End: req.End,
Format: FormatCSV,
}
if _, _, err := engine.GenerateMulti(multi); !errors.Is(err, ErrReportOrgRequired) {
t.Fatalf("GenerateMulti without org: got %v, want ErrReportOrgRequired", err)
}
if _, err := engine.FleetNarrativeFor(multi); !errors.Is(err, ErrReportOrgRequired) {
t.Fatalf("FleetNarrativeFor without org: got %v, want ErrReportOrgRequired", err)
}
if _, _, err := engine.Generate(orgScopeTestRequest("org-unknown", now)); err == nil {
t.Fatal("Generate for an unprovisioned organization should fail")
}
}
func TestReportEngineMultiReportUsesTheRequestOrganizationForEveryResource(t *testing.T) {
now := time.Now()
engine := newOrgScopedTestEngine(t, now)
// A resource entry naming another organization must not redirect its
// query: the multi-report's organization applies to all of them.
resource := orgScopeTestRequest("org-a", now)
data, _, err := engine.GenerateMulti(MultiReportRequest{
OrgID: "org-b",
Resources: []MetricReportRequest{resource},
Start: resource.Start,
End: resource.End,
MetricType: "cpu",
Format: FormatCSV,
})
if err != nil {
t.Fatalf("GenerateMulti: %v", err)
}
csv := string(data)
if !strings.Contains(csv, "77.00") || strings.Contains(csv, "11.00") {
t.Fatalf("multi-report for org-b should carry only org-b's values, got:\n%s", csv)
}
}