mcp connection
This commit is contained in:
632
go-api/internal/mcpserver/tenant_test.go
Normal file
632
go-api/internal/mcpserver/tenant_test.go
Normal file
@@ -0,0 +1,632 @@
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user