@@ -301,9 +301,12 @@ describe("Appsmith API client", () => {
301301} ) ;
302302
303303function 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