Skip to content

Commit 9543878

Browse files
salevineclaude
andcommitted
fix(mcp): scope governance audit reads to the caller's tenant
Reported by Hacktron (medium): the MCP governance audit tools read the mcp_changes collection with no organization predicate. Because isSuperUser is a PER-ORG signal on multi-org (EE) deployments (MANAGE_ORGANIZATION on the caller's own org) and email/actorId is only per-org unique (Migration067), one tenant's admin — or a colliding email — could read another tenant's MCP change history via list_all_changes / get_any_change (and, on collision, list_changes / get_change). Fix (underlying cause, fail-closed at every layer): - McpChangeRecord carries organizationId, stamped on every record at write from the session's tenant. - All four store reads (getChange, listChanges, getAnyChange, listAllChanges) filter by the caller's organization; indexes are org-prefixed. Pre-fix records (no org) match no org-scoped read. - Every audit-read tool refuses with organization_scope_unavailable when the caller's org is unknown, so the empty-org sentinel never runs a read and two tenants can never alias on it — regardless of edition or server version. Governed writes are unaffected. - The caller's tenant is surfaced via a new organizationId on /api/v1/users/me (UserProfileCE_DTO + buildUserProfileDTO), the necessary tenant source for the MCP layer. Regression tests fail on the unpatched code (verified by reverting the filter): real-Mongo cross-tenant isolation (store.integration), always-on tool-layer isolation + fail-closed for admin and actor reads (app.test), a coordinator assertion that org is stamped, and a Java assertion the profile carries organizationId. Council-reviewed (security): APPROVE — reported disclosure fixed and fail-closed at every read path. Ship the server + MCP changes atomically: audit isolation for actor reads is complete only against a server that reports organizationId; otherwise audit reads degrade to unavailable (safe), never a cross-tenant read. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01N19EppQVZ55hwByVy4HEX8
1 parent 3c77fe7 commit 9543878

12 files changed

Lines changed: 484 additions & 89 deletions

File tree

app/client/packages/mcp/src/app.test.ts

Lines changed: 147 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -301,9 +301,12 @@ describe("Appsmith API client", () => {
301301
});
302302

303303
function createApi(
304-
validateToken = jest.fn(async () => ({
304+
// Typed as the real wrapper (() => Promise<unknown>) so a test can pass a custom profile mock without every
305+
// field — the default carries a realistic organizationId so governed audit reads are tenant-scoped.
306+
validateToken: AppsmithApi["validateToken"] = jest.fn(async () => ({
305307
username: "user@appsmith.com",
306308
isAnonymous: false,
309+
organizationId: "org-default",
307310
})),
308311
) {
309312
return (): AppsmithApi => ({
@@ -2647,23 +2650,43 @@ describe("governance-wrapped layout mutations", () => {
26472650
async getChange(
26482651
id: string,
26492652
actorId: string,
2653+
organizationId: string,
26502654
): Promise<McpChangeRecord | undefined> {
2651-
return this.changes.find((c) => c.id === id && c.actorId === actorId);
2655+
return this.changes.find(
2656+
(c) =>
2657+
c.id === id &&
2658+
c.actorId === actorId &&
2659+
c.organizationId === organizationId,
2660+
);
26522661
}
26532662
async listChanges(
26542663
actorId: string,
2664+
organizationId: string,
26552665
limit: number,
26562666
): Promise<McpChangeRecord[]> {
26572667
return this.changes
2658-
.filter((c) => c.actorId === actorId)
2668+
.filter(
2669+
(c) => c.actorId === actorId && c.organizationId === organizationId,
2670+
)
26592671
.slice(-limit)
26602672
.reverse();
26612673
}
2662-
async getAnyChange(id: string): Promise<McpChangeRecord | undefined> {
2663-
return this.changes.find((c) => c.id === id);
2674+
async getAnyChange(
2675+
id: string,
2676+
organizationId: string,
2677+
): Promise<McpChangeRecord | undefined> {
2678+
return this.changes.find(
2679+
(c) => c.id === id && c.organizationId === organizationId,
2680+
);
26642681
}
2665-
async listAllChanges(limit: number): Promise<McpChangeRecord[]> {
2666-
return this.changes.slice(-limit).reverse();
2682+
async listAllChanges(
2683+
organizationId: string,
2684+
limit: number,
2685+
): Promise<McpChangeRecord[]> {
2686+
return this.changes
2687+
.filter((c) => c.organizationId === organizationId)
2688+
.slice(-limit)
2689+
.reverse();
26672690
}
26682691
}
26692692

@@ -3283,9 +3306,15 @@ describe("governance-wrapped layout mutations", () => {
32833306

32843307
it("list_all_changes: an admin sees cross-actor records; a non-admin is refused (M2-T2)", async () => {
32853308
const store = new MemoryGovernanceStore();
3286-
const mk = (id: string, actorId: string, ms: number): McpChangeRecord => ({
3309+
const mk = (
3310+
id: string,
3311+
actorId: string,
3312+
organizationId: string,
3313+
ms: number,
3314+
): McpChangeRecord => ({
32873315
id,
32883316
actorId,
3317+
organizationId,
32893318
entityKey: "e",
32903319
operation: "op",
32913320
revisionBefore: "",
@@ -3295,10 +3324,12 @@ describe("governance-wrapped layout mutations", () => {
32953324
summary: {},
32963325
});
32973326

3298-
await store.saveChange(mk("c1", "alice@appsmith.com", 1000));
3299-
await store.saveChange(mk("c2", "bob@appsmith.com", 2000));
3327+
// Two records in org "acme" and one in a DIFFERENT tenant "globex" — the acme admin must never see globex.
3328+
await store.saveChange(mk("c1", "alice@appsmith.com", "acme", 1000));
3329+
await store.saveChange(mk("c2", "bob@appsmith.com", "acme", 2000));
3330+
await store.saveChange(mk("c3", "carol@globex.com", "globex", 3000));
33003331

3301-
// Non-admin (default validateToken has no adminSettingsVisible) -> refused, no records leaked.
3332+
// Non-admin (default validateToken reports no isSuperUser) -> refused, no records leaked.
33023333
const nonAdmin = createMcpHttpServer(API_BASE_URL, createApi(), {
33033334
governance: new McpGovernanceCoordinator(store),
33043335
});
@@ -3307,14 +3338,15 @@ describe("governance-wrapped layout mutations", () => {
33073338
expect(denied.body.code).toBe("admin_required");
33083339
expect(denied.body.changes).toBeUndefined();
33093340

3310-
// Admin (validateToken reports isSuperUser: true) -> sees BOTH actors' records.
3341+
// Admin of org "acme" (isSuperUser is a PER-ORG signal in EE) -> sees acme's cross-actor records ONLY.
33113342
const adminServer = createMcpHttpServer(
33123343
API_BASE_URL,
33133344
createApi(
33143345
jest.fn(async () => ({
33153346
username: "admin@appsmith.com",
33163347
isAnonymous: false,
33173348
isSuperUser: true,
3349+
organizationId: "acme",
33183350
})),
33193351
),
33203352
{ governance: new McpGovernanceCoordinator(store) },
@@ -3325,6 +3357,10 @@ describe("governance-wrapped layout mutations", () => {
33253357
"c1",
33263358
"c2",
33273359
]);
3360+
// Cross-tenant isolation: globex's record is NEVER returned to the acme admin (the reported vulnerability).
3361+
expect(all.body.changes.some((c: { id: string }) => c.id === "c3")).toBe(
3362+
false,
3363+
);
33283364
// Cross-actor audit MUST attribute each record to its actor.
33293365
const byId = Object.fromEntries(
33303366
all.body.changes.map((c: { id: string; actorId: string }) => [
@@ -3338,13 +3374,20 @@ describe("governance-wrapped layout mutations", () => {
33383374
// The rollback SNAPSHOT object is never exposed even to an admin (only the rollbackAvailable boolean is).
33393375
expect(all.body.changes[0].rollback).toBeUndefined();
33403376

3341-
// get_any_change: admin can fetch a specific cross-actor record (attributed); non-admin is refused.
3377+
// get_any_change: acme admin can fetch an acme record but NOT globex's, even knowing its id.
33423378
const anyChange = await callTool(adminServer, "get_any_change", {
33433379
changeId: "c2",
33443380
});
33453381

33463382
expect(anyChange.body.change.actorId).toBe("bob@appsmith.com");
33473383

3384+
const crossTenant = await callTool(adminServer, "get_any_change", {
3385+
changeId: "c3",
3386+
});
3387+
3388+
expect(crossTenant.body.change).toBeUndefined();
3389+
expect(crossTenant.body.error).toBe("change not found");
3390+
33483391
const deniedGet = await callTool(nonAdmin, "get_any_change", {
33493392
changeId: "c2",
33503393
});
@@ -3357,6 +3400,71 @@ describe("governance-wrapped layout mutations", () => {
33573400
expect(own.body.changes).toEqual([]);
33583401
});
33593402

3403+
it("list_all_changes / get_any_change fail closed when the caller's organization is unknown (M2-T2 tenant isolation)", async () => {
3404+
const store = new MemoryGovernanceStore();
3405+
3406+
await store.saveChange({
3407+
id: "c1",
3408+
actorId: "alice@appsmith.com",
3409+
organizationId: "acme",
3410+
entityKey: "e",
3411+
operation: "op",
3412+
revisionBefore: "",
3413+
revisionAfter: "",
3414+
createdAt: new Date(1000),
3415+
rollback: {},
3416+
summary: {},
3417+
});
3418+
3419+
// Admin WITHOUT an organization on the profile (e.g. an older server that does not report it). The audit read
3420+
// cannot be tenant-scoped, so it must refuse rather than run an unscoped cross-tenant query.
3421+
const adminNoOrg = createMcpHttpServer(
3422+
API_BASE_URL,
3423+
createApi(
3424+
jest.fn(async () => ({
3425+
username: "admin@appsmith.com",
3426+
isAnonymous: false,
3427+
isSuperUser: true,
3428+
})),
3429+
),
3430+
{ governance: new McpGovernanceCoordinator(store) },
3431+
);
3432+
3433+
const listed = await callTool(adminNoOrg, "list_all_changes", {});
3434+
3435+
expect(listed.body.code).toBe("organization_scope_unavailable");
3436+
expect(listed.body.changes).toBeUndefined();
3437+
3438+
const fetched = await callTool(adminNoOrg, "get_any_change", {
3439+
changeId: "c1",
3440+
});
3441+
3442+
expect(fetched.body.code).toBe("organization_scope_unavailable");
3443+
expect(fetched.body.change).toBeUndefined();
3444+
3445+
// The ACTOR-scoped reads fail closed too: without an org they cannot be tenant-bounded, so two tenants could
3446+
// otherwise alias on the empty-org sentinel (colliding email is only per-org unique).
3447+
const noOrg = createMcpHttpServer(
3448+
API_BASE_URL,
3449+
createApi(
3450+
jest.fn(async () => ({
3451+
username: "alice@appsmith.com",
3452+
isAnonymous: false,
3453+
})),
3454+
),
3455+
{ governance: new McpGovernanceCoordinator(store) },
3456+
);
3457+
3458+
const ownList = await callTool(noOrg, "list_changes", {});
3459+
3460+
expect(ownList.body.code).toBe("organization_scope_unavailable");
3461+
expect(ownList.body.changes).toBeUndefined();
3462+
3463+
const ownGet = await callTool(noOrg, "get_change", { changeId: "c1" });
3464+
3465+
expect(ownGet.body.code).toBe("organization_scope_unavailable");
3466+
});
3467+
33603468
it("get_capabilities advertises EXACTLY the registered tools (no drift), under all gates", async () => {
33613469
for (const gate of [
33623470
{ dataEnabled: false, governed: false, js: false },
@@ -5145,23 +5253,43 @@ describe("M5 — build_application URLs + auto-publish", () => {
51455253
async getChange(
51465254
id: string,
51475255
actorId: string,
5256+
organizationId: string,
51485257
): Promise<McpChangeRecord | undefined> {
5149-
return this.changes.find((c) => c.id === id && c.actorId === actorId);
5258+
return this.changes.find(
5259+
(c) =>
5260+
c.id === id &&
5261+
c.actorId === actorId &&
5262+
c.organizationId === organizationId,
5263+
);
51505264
}
51515265
async listChanges(
51525266
actorId: string,
5267+
organizationId: string,
51535268
limit: number,
51545269
): Promise<McpChangeRecord[]> {
51555270
return this.changes
5156-
.filter((c) => c.actorId === actorId)
5271+
.filter(
5272+
(c) => c.actorId === actorId && c.organizationId === organizationId,
5273+
)
51575274
.slice(-limit)
51585275
.reverse();
51595276
}
5160-
async getAnyChange(id: string): Promise<McpChangeRecord | undefined> {
5161-
return this.changes.find((c) => c.id === id);
5277+
async getAnyChange(
5278+
id: string,
5279+
organizationId: string,
5280+
): Promise<McpChangeRecord | undefined> {
5281+
return this.changes.find(
5282+
(c) => c.id === id && c.organizationId === organizationId,
5283+
);
51625284
}
5163-
async listAllChanges(limit: number): Promise<McpChangeRecord[]> {
5164-
return this.changes.slice(-limit).reverse();
5285+
async listAllChanges(
5286+
organizationId: string,
5287+
limit: number,
5288+
): Promise<McpChangeRecord[]> {
5289+
return this.changes
5290+
.filter((c) => c.organizationId === organizationId)
5291+
.slice(-limit)
5292+
.reverse();
51655293
}
51665294
}
51675295

0 commit comments

Comments
 (0)