Skip to content

Commit fbdb2b2

Browse files
feat(mcp,vectordb): tenant ctx + vector tenant isolation (RAN-39 + RAN-20) (#31)
* feat(mcp): tenant ctx through GraphRAG handlers + merge-gate isolation test (RAN-39) Threads the tenant resolved by the MCP transport (X-Tenant-ID header → ctx) into every GraphRAG-backed tool handler and adds the merge-gate integration test that asserts cross-tenant isolation for the full GraphRAG-backed MCP surface. mcp/tools.go - get_system_graph and get_service_health now accept ctx and route through GraphRAG.ServiceMap / AllServiceEdges so they pick up RAN-37's per-tenant in-memory partitioning. Legacy svcGraph remains as a fallback path only when GraphRAG isn't wired (boot windows, future test harnesses). - All other GraphRAG handlers were already ctx-threaded after RAN-37/38; no behavior change for those. vectordb/index.go (+ main.go, api/similar_handler.go) - vectordb.Index.Add and Search now take a tenant string; LogVector and SearchResult carry the tenant tag and Search filters by it. RAN-37 already added tenant args at the call sites in graphrag/clustering.go and mcp/tools.go but the matching vectordb signature change had not landed, leaving the branch unbuildable. This closes that gap with the smallest surgical change; the broader vectordb rework remains RAN-20. - main.go now passes l.TenantID into vectorIdx.Add on hydration and the live ingest hook; api/similar_handler resolves tenant from the request context before searching. graphrag/builder.go - New RegisterAnomaly(tenant, AnomalyNode) — small public API symmetric with PersistInvestigation, used by the new isolation test to seed per-tenant anomalies without depending on the throttled detector loop. mcp/tenant_isolation_test.go - Stands up an in-process MCP server (httptest) wired to GraphRAG over in-memory SQLite, seeds three tenants (acme, beta, default) with overlapping service_name / trace_id / span_id / Drain template / log body / snapshot, and exercises every GraphRAG-backed tool — get_service_map, get_service_health, get_error_chains, trace_graph, impact_analysis, root_cause_analysis, correlated_signals, get_anomaly_timeline, get_investigations, get_investigation (own + cross-tenant id-guess), get_graph_snapshot, find_similar_logs, get_system_graph — three times each (X-Tenant-ID acme, X-Tenant-ID beta, no header → DefaultTenantID). Each response is scanned for the caller's own tenant marker and for any other seeded tenant's marker (service name, log body, op name, anomaly evidence, snapshot id) to prove no cross-tenant leak. Verified: go vet ./... clean; go test ./... clean; go test -race ./internal/{mcp,graphrag}/... clean. Co-Authored-By: Paperclip <noreply@paperclip.ing> * fix(mcp,vectordb): RAN-39 reviewer follow-ups + RAN-20 vector tenant isolation Reviewer (cf5145d8) requested three changes on commit c839460. Addresses each with verified failure-on-regression checks. #1 — Cross-tenant boot hydration of vector index Previously main.go hydrated the index via repo.GetLogsV2(appCtx, ...) which is tenant-scoped to whatever tenant ctx carries — appCtx has none, so only the default tenant's rows reloaded after restart and find_similar_logs was cold for every other tenant until fresh ERROR logs landed. The new tenant-aware vectorIdx.Add(..., l.TenantID, ...) didn't fix it because the non-default rows were never fetched. - internal/storage/log_repo.go: new ListRecentHighSeverityLogsAllTenants — explicitly cross-tenant administrative read used only by hydration. Each row carries its own TenantID; fan-out happens in the caller. - main.go: hydration now uses the new method so every tenant's warm index survives a restart. - internal/vectordb/index.go: tenant-aware FIFO eviction. At cap, drop up to maxSize/10 of the inserting tenant's oldest rows so a noisy tenant cannot evict another tenant's warm rows (availability isolation; confidentiality is still enforced by doc.Tenant filtering in Search). Brand-new tenants drop one globally-oldest row to claim a first slot. #2 — root_cause_analysis assertion was vacuous internal/mcp/tenant_isolation_test.go:434 passed an empty ownMarker to assertNoLeak, so the merge gate would still pass if the tool regressed to returning [] for every tenant. Now passes ownService (RankedCause carries Service so it must appear in a non-empty response). Verified by sabotaging the handler to return a nil slice — the assertion fails with 'expected own marker "acme-orders" in response, body=null' as designed, then reverted. #3 — Drain cluster-id test didn't actually compare cluster IDs The previous test reused seedTenant (per-tenant service names) and only scanned response text, so a regression that surfaced the same cluster row across tenants would still pass. Rewritten to: - Use one shared service name across both tenants so Drain produces colliding (service, templateID) keys — the SignalStore partition is the only thing keeping rows separate. - Inspect the actual []graphrag.LogClusterNode returned by CorrelatedSignals (not just response text), checking Template + SampleLog content for own-marker presence and foreign-marker absence. - Log the per-tenant cluster IDs so future refactors that change the ID scheme leave a visible audit trail. - End-to-end probe via the MCP HTTP surface remains, asserting the same isolation reaches clients. RAN-20 supporting tests internal/vectordb/index_test.go, internal/api/similar_handler_test.go, internal/mcp/tools_ran20_test.go cover vectordb tenant scoping at three layers (in-memory, REST handler, MCP tool). They were sitting untracked on the branch from the parallel RAN-20 work; bundling them so the follow-up vectordb behavior added here is covered. Verified: - go vet ./... clean - go test ./... clean (full repo) - go test -race ./internal/{mcp,graphrag,vectordb,api}/... clean Co-Authored-By: Paperclip <noreply@paperclip.ing> * fix(mcp,graphrag): wire tenant from ctx into vectordb.Search call sites (RAN-20) The vectordb.Index.Search signature went tenant-aware in the prior commit but two call sites still used the legacy 2-arg form, leaving the branch unbuildable: - internal/mcp/tools.go: toolFindSimilarLogs had a TODO(RAN-20) marker and `_ = ctx`. Now resolves tenant via storage.TenantFromContext on mcpCtx(ctx) and passes it to Search. - internal/graphrag/clustering.go: SimilarErrors had the same TODO and `_ = ctx`. Now resolves tenant from the same ctx and passes it through. Both surfaces share the storage tenant-context idiom used everywhere else, so coercion rules (empty → DefaultTenantID) stay consistent. Verified: go build ./... clean, go vet ./... clean, go test -race ./internal/{vectordb,api,mcp,graphrag,storage}/... clean. Co-Authored-By: Paperclip <noreply@paperclip.ing> --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
1 parent 093d693 commit fbdb2b2

11 files changed

Lines changed: 1121 additions & 35 deletions

File tree

internal/api/similar_handler.go

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import (
44
"encoding/json"
55
"net/http"
66
"strconv"
7+
8+
"github.com/RandomCodeSpace/otelcontext/internal/storage"
79
)
810

911
// handleGetSimilarLogs handles GET /api/logs/similar?q=<text>&limit=10
@@ -30,7 +32,8 @@ func (s *Server) handleGetSimilarLogs(w http.ResponseWriter, r *http.Request) {
3032
limit = 50
3133
}
3234

33-
results := s.vectorIdx.Search(query, limit)
35+
tenant := storage.TenantFromContext(r.Context())
36+
results := s.vectorIdx.Search(tenant, query, limit)
3437

3538
w.Header().Set("Content-Type", "application/json")
3639
_ = json.NewEncoder(w).Encode(map[string]any{
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
package api
2+
3+
import (
4+
"encoding/json"
5+
"net/http"
6+
"net/http/httptest"
7+
"net/url"
8+
"testing"
9+
10+
"github.com/RandomCodeSpace/otelcontext/internal/config"
11+
"github.com/RandomCodeSpace/otelcontext/internal/vectordb"
12+
)
13+
14+
// TestSimilarHandler_TenantIsolation is the RAN-20 acceptance bar for the HTTP
15+
// surface. Two tenants with distinct corpora query /api/logs/similar; each
16+
// sees ZERO rows belonging to the other tenant.
17+
func TestSimilarHandler_TenantIsolation(t *testing.T) {
18+
idx := vectordb.New(1_000)
19+
idx.Add(101, "acme", "checkout", "ERROR", "payment gateway timeout charging customer")
20+
idx.Add(102, "acme", "checkout", "ERROR", "payment gateway refused charge insufficient funds")
21+
idx.Add(201, "globex", "auth", "ERROR", "payment gateway token expired for session")
22+
idx.Add(202, "globex", "auth", "ERROR", "payment gateway 500 internal error while authenticating")
23+
24+
srv := &Server{vectorIdx: idx}
25+
mux := http.NewServeMux()
26+
mux.HandleFunc("GET /api/logs/similar", srv.handleGetSimilarLogs)
27+
handler := TenantMiddleware(&config.Config{DefaultTenant: "default"})(mux)
28+
29+
acmeIDs := map[float64]bool{101: true, 102: true}
30+
globexIDs := map[float64]bool{201: true, 202: true}
31+
32+
q := url.Values{}
33+
q.Set("q", "payment gateway")
34+
q.Set("limit", "50")
35+
path := "/api/logs/similar?" + q.Encode()
36+
37+
// Tenant A
38+
aRec := httptest.NewRecorder()
39+
aReq := httptest.NewRequest(http.MethodGet, path, nil)
40+
aReq.Header.Set(TenantHeader, "acme")
41+
handler.ServeHTTP(aRec, aReq)
42+
if aRec.Code != http.StatusOK {
43+
t.Fatalf("acme: want 200, got %d body=%q", aRec.Code, aRec.Body.String())
44+
}
45+
acme := decodeResults(t, aRec)
46+
if len(acme) == 0 {
47+
t.Fatalf("acme got zero hits despite matching corpus")
48+
}
49+
for _, r := range acme {
50+
if !acmeIDs[r.ID] {
51+
t.Fatalf("acme leaked cross-tenant id=%v tenant=%q body=%q", r.ID, r.Tenant, r.Body)
52+
}
53+
}
54+
55+
// Tenant B
56+
gRec := httptest.NewRecorder()
57+
gReq := httptest.NewRequest(http.MethodGet, path, nil)
58+
gReq.Header.Set(TenantHeader, "globex")
59+
handler.ServeHTTP(gRec, gReq)
60+
if gRec.Code != http.StatusOK {
61+
t.Fatalf("globex: want 200, got %d", gRec.Code)
62+
}
63+
globex := decodeResults(t, gRec)
64+
if len(globex) == 0 {
65+
t.Fatalf("globex got zero hits despite matching corpus")
66+
}
67+
for _, r := range globex {
68+
if !globexIDs[r.ID] {
69+
t.Fatalf("globex leaked cross-tenant id=%v tenant=%q body=%q", r.ID, r.Tenant, r.Body)
70+
}
71+
}
72+
}
73+
74+
// TestSimilarHandler_UnknownTenantReturnsEmpty confirms a request bearing an
75+
// unknown tenant header returns zero results — the handler must not silently
76+
// fall back to another tenant's rows.
77+
func TestSimilarHandler_UnknownTenantReturnsEmpty(t *testing.T) {
78+
idx := vectordb.New(100)
79+
idx.Add(1, "acme", "svc", "ERROR", "database connection refused upstream")
80+
81+
srv := &Server{vectorIdx: idx}
82+
mux := http.NewServeMux()
83+
mux.HandleFunc("GET /api/logs/similar", srv.handleGetSimilarLogs)
84+
handler := TenantMiddleware(&config.Config{DefaultTenant: "default"})(mux)
85+
86+
rec := httptest.NewRecorder()
87+
req := httptest.NewRequest(http.MethodGet, "/api/logs/similar?q=database+connection", nil)
88+
req.Header.Set(TenantHeader, "initech")
89+
handler.ServeHTTP(rec, req)
90+
91+
if rec.Code != http.StatusOK {
92+
t.Fatalf("want 200, got %d", rec.Code)
93+
}
94+
if r := decodeResults(t, rec); len(r) != 0 {
95+
t.Fatalf("unknown tenant saw %d cross-tenant hits", len(r))
96+
}
97+
}
98+
99+
type similarResult struct {
100+
ID float64 `json:"LogID"`
101+
Tenant string `json:"Tenant"`
102+
ServiceName string `json:"ServiceName"`
103+
Severity string `json:"Severity"`
104+
Body string `json:"Body"`
105+
Score float64 `json:"Score"`
106+
}
107+
108+
func decodeResults(t *testing.T, rec *httptest.ResponseRecorder) []similarResult {
109+
t.Helper()
110+
var env struct {
111+
Results []similarResult `json:"results"`
112+
}
113+
if err := json.Unmarshal(rec.Body.Bytes(), &env); err != nil {
114+
t.Fatalf("decode response: %v (body=%q)", err, rec.Body.String())
115+
}
116+
return env.Results
117+
}

internal/graphrag/builder.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,19 @@ func (g *GraphRAG) DroppedMetricsCount() int64 { return g.droppedMetrics.Load()
154154
// tests to assert cooldown behavior without requiring a live repo.
155155
func (g *GraphRAG) InvestigationInsertCount() int64 { return g.invInserts.Load() }
156156

157+
// RegisterAnomaly inserts an anomaly into the AnomalyStore for tenant.
158+
// Mirrors PersistInvestigation's "tenant accepted explicitly" shape so
159+
// out-of-band anomaly producers (synthetic detectors, integration tests,
160+
// future external anomaly feeds) can land directly on the right tenant
161+
// slice without going through the metric/error detection loops. Empty
162+
// tenant collapses to storage.DefaultTenantID.
163+
func (g *GraphRAG) RegisterAnomaly(tenant string, anomaly AnomalyNode) {
164+
if tenant == "" {
165+
tenant = storage.DefaultTenantID
166+
}
167+
g.storesForTenant(tenant).anomalies.AddAnomaly(anomaly)
168+
}
169+
157170
// recordEventDrop increments the per-signal atomic counter and — when
158171
// a telemetry registry is wired — the Prometheus counter vec.
159172
func (g *GraphRAG) recordEventDrop(signal string) {

internal/graphrag/clustering.go

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ import (
99
"context"
1010
"fmt"
1111
"time"
12+
13+
"github.com/RandomCodeSpace/otelcontext/internal/storage"
1214
)
1315

1416
// clusterLog runs the log body through Drain and upserts a LogClusterNode
@@ -78,14 +80,11 @@ func (g *GraphRAG) SimilarErrors(ctx context.Context, clusterID string, k int) [
7880
if query == "" && len(cluster.TemplateTokens) > 0 {
7981
query = joinTokens(cluster.TemplateTokens)
8082
}
81-
// TODO(RAN-20): vectordb.Index.Search itself is not yet tenant-scoped on
82-
// `main`, so we call the 2-arg signature here. Tenant isolation for the
83-
// SignalStore lookup above is already enforced via storesFor(ctx); the
84-
// vector hits are then narrowed by the EmittedBy edges in this tenant's
85-
// SignalStore on lines below, so cross-tenant hits cannot surface even
86-
// while the underlying vector index is shared.
87-
_ = ctx
88-
results := g.vectorIdx.Search(query, k*2) // over-fetch to filter
83+
// vectordb.Index.Search takes the tenant string directly; resolve it
84+
// from ctx via the same storage helper used by storesFor so both sides
85+
// agree on coercion rules (empty → DefaultTenantID).
86+
tenant := storage.TenantFromContext(ctx)
87+
results := g.vectorIdx.Search(tenant, query, k*2) // over-fetch to filter
8988

9089
// Map results back to log clusters.
9190
seen := map[string]bool{clusterID: true}

0 commit comments

Comments
 (0)