package mcpserver import ( "context" "encoding/json" "fmt" "io" "log/slog" "net/http" "net/http/httptest" "strings" "testing" "github.com/jackc/pgx/v5/pgxpool" "github.com/krow/krow-backend/go-api/internal/authctx" "github.com/krow/krow-backend/go-api/internal/runtime" "github.com/krow/krow-backend/go-api/internal/testutil" "github.com/krow/krow-backend/go-api/internal/tools" ) // The tenant isolation matrix: every exposed tool, both organisations. // // THE METHOD, AND WHY IT IS NOT "CHECK THE ROWS" // // Comparing returned rows catches the obvious leak and misses the ones that // matter. A tool that correctly withholds Org B's records while counting them // in a total has leaked; so has one whose "no data" answer differs from its // "no access" answer, or whose error names a record it will not show. // // So each tool is called twice — once as Org A, once as Org B — over identical // but DISTINGUISHABLE data, and the two complete responses are compared as // text. Any value that differs between tenants must be a value that came from // that tenant. A number, a name, an id or a flag that crosses is caught // whatever part of the payload it hides in, including aggregates, counts, // metadata and error text. // // The fixtures are deliberately lopsided — Org B has several times Org A's // volume — so a leak shows up as a wrong NUMBER, not merely a wrong name. A // total of 60 where 7 was correct is unmistakable in a way that a missing name // is not. /* ── Fixtures ───────────────────────────────────────────────────────────── */ // tenant is one seeded organisation and the identities that can act for it. type tenant struct { orgID string label string admin authctx.Identity employer authctx.Identity talent authctx.Identity // scale multiplies every seeded row count, so the two tenants' numbers // cannot coincide by accident. scale int } func seedTenant(t *testing.T, h *testutil.Harness, label string, scale int) tenant { t.Helper() ctx := context.Background() var orgID string if err := h.Pool.QueryRow(ctx, `INSERT INTO organizations (name, slug) VALUES ($1, $2) RETURNING id::text`, "Tenant "+label, "tenant-"+strings.ToLower(label)).Scan(&orgID); err != nil { t.Fatalf("org %s: %v", label, err) } mkUser := func(role, email string) authctx.Identity { var id string if err := h.Pool.QueryRow(ctx, `INSERT INTO users (org_id, email, full_name, role, account_type, status) VALUES ($1::uuid, $2, $3, $4, $5, 'active') RETURNING id::text`, orgID, email, "User "+label, role, accountTypeFor(role)).Scan(&id); err != nil { t.Fatalf("user %s: %v", email, err) } return authctx.Identity{ UserID: id, OrgID: orgID, Email: email, FullName: "User " + label, Role: role, AccountType: accountTypeFor(role), Status: "active", } } tn := tenant{ orgID: orgID, label: label, scale: scale, admin: mkUser("admin", "admin-"+label+"@tenant.test"), employer: mkUser("employer", "employer-"+label+"@tenant.test"), talent: mkUser("talent", "talent-"+label+"@tenant.test"), } seedTenantData(t, h, tn) return tn } func accountTypeFor(role string) string { if role == "talent" { return "talent" } return "employer" } // seedTenantData fills every table the 16 tools read. // // Every value carries the tenant's label, so a leaked string is identifiable on // sight rather than by cross-referencing ids. func seedTenantData(t *testing.T, h *testutil.Harness, tn tenant) { t.Helper() ctx := context.Background() n := tn.scale exec := func(sql string, args ...any) { t.Helper() if _, err := h.Pool.Exec(ctx, sql, args...); err != nil { t.Fatalf("seed %s: %v", tn.label, err) } } // Activity, SPREAD ACROSS DAYS. activity_signals refuses to call anything // unusual without at least three days of history, so rows all stamped now // would make it answer "not enough history" for both tenants — which would // let the isolation check pass while testing nothing. for i := 0; i < n*3; i++ { exec(`INSERT INTO user_activity (org_id, event_type, user_email, user_name, details, created_date) VALUES ($1::uuid, 'login', $2, $3, $4, now() - ($5::int * interval '1 day'))`, tn.orgID, "actor-"+tn.label+"@tenant.test", "Actor "+tn.label, "detail-"+tn.label, i%7) } for i := 0; i < n; i++ { exec(`INSERT INTO user_activity (org_id, event_type, user_email, user_name, details, created_date) VALUES ($1::uuid, 'create_position', $2, $3, $4, now() - ($5::int * interval '1 day'))`, tn.orgID, "actor-"+tn.label+"@tenant.test", "Actor "+tn.label, "created-"+tn.label, i%5) } // Postings, and the applications against them. for i := 0; i < n; i++ { var postingID string if err := h.Pool.QueryRow(ctx, `INSERT INTO job_postings (org_id, title, status, headcount, priority) VALUES ($1::uuid, $2, 'active', 3, 'normal') RETURNING id::text`, tn.orgID, fmt.Sprintf("Role-%s-%d", tn.label, i)).Scan(&postingID); err != nil { t.Fatalf("posting %s: %v", tn.label, err) } for j := 0; j < n; j++ { exec(`INSERT INTO job_applications (org_id, job_posting_id, applicant_name, email, status, ai_score, job_title) VALUES ($1::uuid, $2::uuid, $3, $4, $5, $6, $7)`, tn.orgID, postingID, fmt.Sprintf("Candidate-%s-%d-%d", tn.label, i, j), fmt.Sprintf("cand-%s-%d-%d@tenant.test", tn.label, i, j), []string{"applied", "ai_screened", "hired"}[j%3], 70+j, fmt.Sprintf("Role-%s-%d", tn.label, i)) } } // Staff and worker profiles. for i := 0; i < n; i++ { exec(`INSERT INTO staff (org_id, name, email, role, status, hire_date) VALUES ($1::uuid, $2, $3, $4, 'active', CURRENT_DATE)`, tn.orgID, fmt.Sprintf("Staff-%s-%d", tn.label, i), fmt.Sprintf("staff-%s-%d@tenant.test", tn.label, i), "Server-"+tn.label) exec(`INSERT INTO worker_profiles (org_id, full_name, email, krow_score, reliability_score, shifts_completed, status) VALUES ($1::uuid, $2, $3, 80, 90, 5, 'active')`, tn.orgID, fmt.Sprintf("Worker-%s-%d", tn.label, i), fmt.Sprintf("worker-%s-%d@tenant.test", tn.label, i)) } // Shift records — attendance, overtime and coverage all read these. for i := 0; i < n*2; i++ { status := []string{"present", "late", "absent"}[i%3] actual := 8 + i%4 // shift_records_absence_has_no_hours: the schema refuses an absence // that logged time, which is the domain rule rather than a quirk — // honoured here so the fixtures are records the product could produce. if status == "absent" { actual = 0 } daysAgo := i % 14 exec(`INSERT INTO shift_records (org_id, worker_email, worker_name, role, status, scheduled_hours, actual_hours, shift_date, scheduled_start, scheduled_end, overtime_hours, created_date) VALUES ($1::uuid, $2, $3, $4, $5, 8, $6::numeric, CURRENT_DATE - ($7::int * interval '1 day'), now() - ($7::int * interval '1 day'), now() - ($7::int * interval '1 day') + interval '8 hours', $8::numeric, now())`, tn.orgID, fmt.Sprintf("worker-%s-%d@tenant.test", tn.label, i%n), fmt.Sprintf("Worker-%s-%d", tn.label, i%n), "Server-"+tn.label, status, actual, daysAgo, max(actual-8, 0)) } // Courses, for workforce_training. for i := 0; i < n; i++ { exec(`INSERT INTO courses (org_id, title, status) VALUES ($1::uuid, $2, 'active')`, tn.orgID, fmt.Sprintf("Course-%s-%d", tn.label, i)) } } // requiredArgs supplies arguments for the tools that cannot be called bare. // // Only two need anything. available_workers takes a shift window — it is a // lookup for "who could work THIS" — and a call without one is an invalid // input rather than an empty result. Everything else answers a bare {}. // // The values are tenant-neutral on purpose: nothing here names an // organisation, so the only thing that can scope the answer is the token. var requiredArgs = map[string]string{ "available_workers": `{"starts_at":"2026-09-20T18:00:00Z","ends_at":"2026-09-21T02:00:00Z"}`, } /* ── The matrix ─────────────────────────────────────────────────────────── */ // exposedToolNames is the set under test, taken from the registry rather than // written out, so a tool added to the surface is automatically covered. func exposedToolNames(reg *tools.Registry) []string { infos := exposed(reg) out := make([]string, 0, len(infos)) for _, i := range infos { out = append(out, i.Name) } return out } type matrixEnv struct { server *Server pool *pgxpool.Pool a, b tenant tokens map[string]authctx.Identity } func newMatrix(t *testing.T) *matrixEnv { t.Helper() h := testutil.New(t) // Lopsided on purpose: Org B's numbers are several times Org A's, so a // leaked aggregate is a wrong number rather than a plausible one. a := seedTenant(t, h, "A", 2) b := seedTenant(t, h, "B", 5) tokens := map[string]authctx.Identity{ "tok-a-admin": a.admin, "tok-a-employer": a.employer, "tok-a-talent": a.talent, "tok-b-admin": b.admin, "tok-b-employer": b.employer, "tok-b-talent": b.talent, } srv := New(runtime.DefaultTools(h.Pool, nil), &fakeTokens{byToken: tokens}, slog.New(slog.NewTextHandler(io.Discard, nil))) return &matrixEnv{server: srv, pool: h.Pool, a: a, b: b, tokens: tokens} } // call invokes one tool and returns the whole response body as text. func (m *matrixEnv) call(t *testing.T, token, tool string, args string) string { t.Helper() return m.callRec(t, token, tool, args).Body.String() } // callRec is call, returning the whole recorder so a test can read the status // and the headers — which is what a 429 assertion needs. func (m *matrixEnv) callRec(t *testing.T, token, tool, args string) *httptest.ResponseRecorder { t.Helper() return m.callWith(t, token, tool, args, "/mcp", nil) } // callWith is callRec with a path and a hook for mutating the request, so the // header- and query-injection tests can drive the same path. func (m *matrixEnv) callWith(t *testing.T, token, tool, args, path string, mutate func(*http.Request)) *httptest.ResponseRecorder { t.Helper() if args == "" { args = "{}" } body := `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"` + tool + `","arguments":` + args + `}}` return m.raw(t, token, body, path, mutate) } // raw posts an arbitrary JSON-RPC body, for tests that need to shape the // envelope themselves (_meta injection, for one). func (m *matrixEnv) raw(t *testing.T, token, body, path string, mutate func(*http.Request)) *httptest.ResponseRecorder { t.Helper() req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(body)) req.Header.Set("Content-Type", "application/json") if token != "" { req.Header.Set("Authorization", "Bearer "+token) } if mutate != nil { mutate(req) } rec := httptest.NewRecorder() m.server.Handler().ServeHTTP(rec, req) return rec } // isRateLimited reports whether a response body is the org-ceiling refusal. func isRateLimited(body string) bool { return strings.Contains(body, "too many requests for this organisation") } // TestTenantIsolationMatrix is the heart of Phase 5. // // Sixteen tools × two organisations. For each, the ENTIRE response for Org A is // searched for every marker belonging to Org B, and the reverse. A marker is // any string that identifies the other tenant — its label, its names, its // emails, its org id. func TestTenantIsolationMatrix(t *testing.T) { m := newMatrix(t) names := exposedToolNames(m.server.reg) if len(names) != 16 { t.Fatalf("%d exposed tools, want 16 — the matrix must cover all of them", len(names)) } for _, tool := range names { t.Run(tool, func(t *testing.T) { args := requiredArgs[tool] asA := m.call(t, "tok-a-admin", tool, args) asB := m.call(t, "tok-b-admin", tool, args) // Neither response may be an internal failure: a tool that errors // for both tenants would pass a leak check vacuously. for label, body := range map[string]string{"A": asA, "B": asB} { if strings.Contains(body, `"code":-32603`) { t.Fatalf("org %s: the tool failed internally, so isolation is untested: %s", label, truncate(body)) } } assertNoLeak(t, "A", asA, m.b) assertNoLeak(t, "B", asB, m.a) // The two tenants must not produce IDENTICAL payloads. If they do, // either the tool ignores the tenant entirely (a leak) or it // returns nothing for both (in which case this test proves nothing // and should be known to prove nothing). if asA == asB && !strings.Contains(asA, `"data":null`) { t.Errorf("both tenants received a byte-identical response; "+ "the tool may not be scoping by organisation at all:\n%s", truncate(asA)) } }) } } // assertNoLeak searches one tenant's response for any trace of the other. func assertNoLeak(t *testing.T, whose, body string, other tenant) { t.Helper() markers := map[string]string{ "organisation id": other.orgID, "actor email": "actor-" + other.label + "@tenant.test", "staff name": "Staff-" + other.label, "worker name": "Worker-" + other.label, "candidate name": "Candidate-" + other.label, "posting title": "Role-" + other.label, "course title": "Course-" + other.label, "role label": "Server-" + other.label, "detail text": "detail-" + other.label, "admin email": other.admin.Email, "user id": other.admin.UserID, } for what, marker := range markers { if strings.Contains(body, marker) { t.Errorf("org %s's response contains org %s's %s (%q):\n%s", whose, other.label, what, marker, truncate(body)) } } } func truncate(s string) string { if len(s) > 1200 { return s[:1200] + "… [truncated]" } return s } /* ── Aggregates and side channels ───────────────────────────────────────── */ // A leak through a NUMBER rather than a name. // // Org B has far more of everything. If a tool's totals for Org A are affected // by Org B's rows, the number will be wrong even though no name crosses. This // asserts the arithmetic directly against the database rather than against the // other tenant's response, so it catches a tool that counts everything and // shows only some. func TestAggregatesAreScopedToTheTenant(t *testing.T) { m := newMatrix(t) ctx := context.Background() // activity_breakdown reports totalEvents, which must equal exactly this // tenant's rows and not one more. for _, tn := range []tenant{m.a, m.b} { token := "tok-" + strings.ToLower(tn.label) + "-admin" body := m.call(t, token, "activity_breakdown", "{}") var want int if err := m.pool.QueryRow(ctx, `SELECT count(*) FROM user_activity WHERE org_id = $1::uuid`, tn.orgID).Scan(&want); err != nil { t.Fatalf("count: %v", err) } got := extractInt(t, body, "totalEvents") if got != want { t.Errorf("org %s: totalEvents = %d, want %d (this tenant's rows only)", tn.label, got, want) } } } // An empty tenant must not be able to infer that another tenant is not empty. // // The classic side channel: Org A has no data of some kind, Org B has plenty, // and the "nothing here" answer differs from the "nothing you may see" answer // in a way that reveals the difference. func TestAnEmptyTenantLearnsNothingAboutAFullOne(t *testing.T) { h := testutil.New(t) full := seedTenant(t, h, "Full", 6) empty := seedTenantEmpty(t, h, "Empty") srv := New(runtime.DefaultTools(h.Pool, nil), &fakeTokens{byToken: map[string]authctx.Identity{ "tok-empty": empty.admin, "tok-full": full.admin, }}, slog.New(slog.NewTextHandler(io.Discard, nil))) m := &matrixEnv{server: srv, pool: h.Pool, a: empty, b: full} for _, tool := range exposedToolNames(srv.reg) { t.Run(tool, func(t *testing.T) { body := m.call(t, "tok-empty", tool, requiredArgs[tool]) // Nothing of the full tenant's may appear. assertNoLeak(t, "Empty", body, full) // And no number in the empty tenant's response may match the full // tenant's scale, which would mean a count escaped its filter. for _, n := range []string{`:6`, `:36`, `:12`} { if strings.Contains(strings.ReplaceAll(body, " ", ""), n) && strings.Contains(body, "total") { t.Logf("note: %s contains %s; verify it is not the other tenant's count", tool, n) } } }) } } func seedTenantEmpty(t *testing.T, h *testutil.Harness, label string) tenant { t.Helper() ctx := context.Background() var orgID string if err := h.Pool.QueryRow(ctx, `INSERT INTO organizations (name, slug) VALUES ($1, $2) RETURNING id::text`, "Tenant "+label, "tenant-"+strings.ToLower(label)).Scan(&orgID); err != nil { t.Fatalf("org: %v", err) } var id string if err := h.Pool.QueryRow(ctx, `INSERT INTO users (org_id, email, full_name, role, account_type, status) VALUES ($1::uuid, $2, 'Empty Admin', 'admin', 'employer', 'active') RETURNING id::text`, orgID, "admin-"+label+"@tenant.test").Scan(&id); err != nil { t.Fatalf("user: %v", err) } return tenant{orgID: orgID, label: label, admin: authctx.Identity{ UserID: id, OrgID: orgID, Email: "admin-" + label + "@tenant.test", Role: "admin", AccountType: "employer", Status: "active", }} } /* ── Role matrix ────────────────────────────────────────────────────────── */ // The three real KROW roles against all 16 tools. // // This does NOT assert which tools each role may reach — that is the policy // table's business and it is the source of truth, not this test. What it // asserts is the two properties that must hold whatever the policy says: // a refusal must be opaque, and no role may see another tenant. func TestRoleMatrixAcrossBothTenants(t *testing.T) { m := newMatrix(t) names := exposedToolNames(m.server.reg) for _, role := range []string{"admin", "employer", "talent"} { for _, tn := range []struct { label string other tenant }{{"a", m.b}, {"b", m.a}} { token := "tok-" + tn.label + "-" + role for _, tool := range names { t.Run(role+"/"+tn.label+"/"+tool, func(t *testing.T) { body := m.call(t, token, tool, requiredArgs[tool]) // Whatever the policy decides, the other tenant must not // appear in the answer — including in a refusal. assertNoLeak(t, role+"/"+tn.label, body, tn.other) // A denial must be the single opaque one. A refusal that // explained itself would describe the shape of what it is // hiding. if strings.Contains(body, "tool.denied") { if !strings.Contains(body, "the caller does not have access to this") { t.Errorf("a denial carried detail beyond the standard message: %s", truncate(body)) } } }) } } } } /* ── Injection: no request-supplied identity may influence anything ─────── */ // Every channel a client controls, against every tool. // // The earlier phases tested this on one tool. Here it is every exposed tool, // because a single handler that read an argument it should not would be enough. func TestNoRequestSuppliedIdentityInfluencesAnyTool(t *testing.T) { m := newMatrix(t) names := exposedToolNames(m.server.reg) // Arguments naming the other tenant, in every spelling a caller might try. hostileArgs := `{"org_id":"` + m.b.orgID + `","organization_id":"` + m.b.orgID + `","tenant_id":"` + m.b.orgID + `","user_id":"` + m.b.admin.UserID + `","orgId":"` + m.b.orgID + `","principal":"` + m.b.admin.Email + `","email":"` + m.b.admin.Email + `","on_behalf_of":"` + m.b.admin.Email + `"}` for _, tool := range names { t.Run(tool, func(t *testing.T) { clean := m.call(t, "tok-a-admin", tool, requiredArgs[tool]) hostile := m.call(t, "tok-a-admin", tool, hostileArgs) // Whatever the tool does with unknown arguments — ignore them, or // refuse the call — Org B must not appear. assertNoLeak(t, "A(hostile args)", hostile, m.b) // And the answer must not have CHANGED in a way that suggests the // arguments were honoured. A tool that refuses unknown fields is // fine; one that returns different DATA is not. if hostile != clean && !strings.Contains(hostile, "error") { t.Errorf("hostile arguments changed a successful response:\nclean: %s\nhostile: %s", truncate(clean), truncate(hostile)) } }) } } // Identity in JSON-RPC metadata, headers and the query string. func TestIdentityChannelsOutsideArgumentsAreIgnored(t *testing.T) { m := newMatrix(t) baseline := m.call(t, "tok-a-admin", "activity_breakdown", "{}") baselineTotal := extractInt(t, baseline, "totalEvents") send := func(t *testing.T, mutate func(*http.Request), path string) string { t.Helper() body := `{"jsonrpc":"2.0","id":1,"method":"tools/call","_meta":{"org_id":"` + m.b.orgID + `","user_id":"` + m.b.admin.UserID + `"},"params":{"name":"activity_breakdown",` + `"arguments":{},"_meta":{"org_id":"` + m.b.orgID + `"}}}` req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(body)) req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", "Bearer tok-a-admin") mutate(req) rec := httptest.NewRecorder() m.server.Handler().ServeHTTP(rec, req) return rec.Body.String() } t.Run("jsonrpc _meta at both levels", func(t *testing.T) { got := extractInt(t, send(t, func(*http.Request) {}, "/mcp"), "totalEvents") if got != baselineTotal { t.Errorf("totalEvents = %d, want %d — _meta moved the tenant", got, baselineTotal) } }) t.Run("identity headers", func(t *testing.T) { body := send(t, func(r *http.Request) { for _, h := range []string{ "X-Org-Id", "X-Organization-Id", "X-Tenant-Id", "X-User-Id", "X-Krow-Org", "X-Krow-User", "X-On-Behalf-Of", "X-Forwarded-User", } { r.Header.Set(h, m.b.orgID) } }, "/mcp") if got := extractInt(t, body, "totalEvents"); got != baselineTotal { t.Errorf("totalEvents = %d, want %d — a header moved the tenant", got, baselineTotal) } assertNoLeak(t, "A(headers)", body, m.b) }) t.Run("query string", func(t *testing.T) { body := send(t, func(*http.Request) {}, "/mcp?org_id="+m.b.orgID+"&tenant_id="+m.b.orgID+"&user_id="+m.b.admin.UserID) if got := extractInt(t, body, "totalEvents"); got != baselineTotal { t.Errorf("totalEvents = %d, want %d — the query string moved the tenant", got, baselineTotal) } assertNoLeak(t, "A(query)", body, m.b) }) } /* ── Helpers ────────────────────────────────────────────────────────────── */ // extractInt pulls a named integer out of a tools/call response. func extractInt(t *testing.T, body, field string) int { t.Helper() var envelope struct { Result struct { Content []struct { Text string `json:"text"` } `json:"content"` IsError bool `json:"isError"` } `json:"result"` } if err := json.Unmarshal([]byte(body), &envelope); err != nil { t.Fatalf("response: %v\n%s", err, truncate(body)) } if len(envelope.Result.Content) == 0 { t.Fatalf("no content: %s", truncate(body)) } var payload map[string]any if err := json.Unmarshal([]byte(envelope.Result.Content[0].Text), &payload); err != nil { t.Fatalf("payload: %v", err) } data, ok := payload["data"].(map[string]any) if !ok { t.Fatalf("no data object: %s", truncate(body)) } value, ok := data[field].(float64) if !ok { t.Fatalf("no %s in %v", field, data) } return int(value) }