mirror of
https://github.com/hanzoai/observability.git
synced 2026-10-30 20:22:53 +00:00
hanzoai/dashboards shipped as its own binary (msgType 206) serving five console
tRPC routers: dashboards, widgets, table batch-actions, view presets, monitors.
It is now this service's 200+ ordinal range and its dash_*.go files.
They were never two concerns. A dashboard is a saved QUERY over exactly the
traces, observations and scores this service owns, and both halves were blocked
on the SAME ClickHouse shim — kept apart, that shim had to be written twice
against two copies of one OLTP model, and chart/scoreHistogram could not reach
the scores they aggregate without a network hop. The duplication was already
visible in the capability bits: both services had independently minted a
PermAnalyticsRead. Merged, there is one.
Ordinals are the standalone binary's 0–29 offset by 200, not renumbered into the
gaps: the offset is mechanical and reversible, renumbering would silently
repoint a client built against the old ids.
Three conventions were doubled and are now single:
- TENANCY. authorize() answered both "may this caller invoke this method" and
"whose rows may it touch", returning a defaultOrg the handlers ignored while
each re-read projectId from its own params. Split: authorize() is the cap gate,
scope() is the ONE tenant resolver, and its project is passed to every handler.
A pipelined call now INHERITS its target's project instead of re-reading the
payload, so pipelining cannot be used to hop tenants. Server.defaultOrg was
dead once scope() existed and is deleted rather than left as unread state on a
security-relevant struct.
- NOT-IMPLEMENTED. Observability answered stubs 200 + Empty{Stubbed:true};
dashboards answered 501. Unified on 501 through one Server.stub helper. A
stubbed 200 carrying an empty result is indistinguishable at the call site from
a real query that matched nothing — the UI renders "no data" and the missing
backend never surfaces. The status is the one field a caller cannot skip
reading; a flag in the body is one it can, and eventually does.
- COLUMN NAMES. fDashDescription, fWidgetDescription and fDescription were three
constants for the column "description". One constant per column name now.
scope() reads the payload's field at text @0 without knowing its type, which is
silently wrong for any request struct that puts another text field first — and
wrong by reading ANOTHER TENANT'S rows, not by failing. That assumption is now
pinned by TestEveryRequestStructCarriesProjectIdAtZero over the schema itself
(43 declarations, 9 result structs exempted). Verified it fails when the
invariant is broken, not just that it passes.
Verified: 22 tests green (14 obs + 8 dashboards, bodies ported byte-identical so
they diff against the originals) plus the invariant; go vet clean; make zap-gen
byte-identical on regen; CGO_ENABLED=0 build ok.
go.sum carried the same moved-tag corruption fixed in hanzoai/dashboards
(age/pq/zap hashes restored to the checksum DB; threshold v1.9.4→v1.9.5 and
orm v0.6.14→v0.6.16, both unbuildable from a clean cache).
Co-authored-by: Hanzo Dev <[email protected]>
76 lines
2.7 KiB
Go
76 lines
2.7 KiB
Go
package server_test
|
|
|
|
import (
|
|
"os"
|
|
"regexp"
|
|
"strconv"
|
|
"testing"
|
|
)
|
|
|
|
// scope() resolves the tenant by reading the payload's field at offset 0 as
|
|
// text, without knowing which struct it holds:
|
|
//
|
|
// project = p.Root().Text(0) // ProjectId is text @0 in every request struct
|
|
//
|
|
// That is fast and type-agnostic, and it is silently wrong the moment a request
|
|
// struct puts some other text field first — the service would then scope the
|
|
// query to whatever that field contains. No test of a single method would catch
|
|
// it; only the method with the misplaced field would misbehave, and it would
|
|
// misbehave by reading ANOTHER TENANT'S ROWS rather than by failing.
|
|
//
|
|
// So the invariant is asserted against the schema itself, not against any one
|
|
// handler: every struct carrying a ProjectId must carry it at @0, except the
|
|
// entity structs below, which are RESULTS (never decoded by scope) and place
|
|
// ProjectId after their id.
|
|
func TestEveryRequestStructCarriesProjectIdAtZero(t *testing.T) {
|
|
// Entity/result structs: returned to callers, never passed to scope().
|
|
entities := map[string]bool{
|
|
"Trace": true, "Observation": true, "Session": true, "Score": true,
|
|
"ScoreConfig": true, "Dashboard": true, "Widget": true, "Preset": true,
|
|
"Monitor": true,
|
|
}
|
|
|
|
src, err := os.ReadFile("../proto/observability.zap")
|
|
if err != nil {
|
|
t.Fatalf("read schema: %v", err)
|
|
}
|
|
// Strip comments so the documentation block (which mentions field names in
|
|
// prose) cannot be mistaken for a declaration.
|
|
clean := regexp.MustCompile(`#[^\n]*`).ReplaceAllString(string(src), "")
|
|
|
|
structRe := regexp.MustCompile(`struct\s+(\w+)\s*\{([^}]*)\}`)
|
|
fieldRe := regexp.MustCompile(`(\w+)\s+([\w<>]+)\s+@(\d+)`)
|
|
|
|
var checked int
|
|
for _, m := range structRe.FindAllStringSubmatch(clean, -1) {
|
|
name, body := m[1], m[2]
|
|
for _, f := range fieldRe.FindAllStringSubmatch(body, -1) {
|
|
if f[1] != "ProjectId" {
|
|
continue
|
|
}
|
|
off, err := strconv.Atoi(f[3])
|
|
if err != nil {
|
|
t.Fatalf("%s.ProjectId: bad offset %q", name, f[3])
|
|
}
|
|
checked++
|
|
if entities[name] {
|
|
continue // a result struct; scope() never sees it
|
|
}
|
|
if off != 0 {
|
|
t.Errorf("%s.ProjectId is @%d, must be @0 — scope() reads text @0 as "+
|
|
"the tenant key, so this struct would scope queries to the wrong "+
|
|
"field. Move ProjectId to @0, or add %s to the entities set if it "+
|
|
"is a result type.", name, off, name)
|
|
}
|
|
if f[2] != "text" {
|
|
t.Errorf("%s.ProjectId is %q, must be text — scope() reads it as text", name, f[2])
|
|
}
|
|
}
|
|
}
|
|
if checked == 0 {
|
|
t.Fatal("no ProjectId fields found — the schema path or parser is wrong, " +
|
|
"so this test was proving nothing")
|
|
}
|
|
t.Logf("scope() invariant holds across %d ProjectId declarations", checked)
|
|
}
|