From 66e6ef2168cd4967340c08c87323a2332a30c70e Mon Sep 17 00:00:00 2001 From: Joachim Rosskopf Date: Mon, 15 Jun 2026 09:46:06 +0200 Subject: [PATCH 1/2] feat(acl): close the resolve() read-gap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit resolve() returned a page ref (existence + page_id + skill) for ANY instance, including owner-private ones the caller cannot read — leaking the existence/id of another member's private records. Mirror the read filter (always-on, like expand/list_instances/search): when resolve hits an owner-private instance the caller can't read, return it as not-found (page: null, exists: false) — exactly as expand returns a null page. Admin + public unaffected. Test (real HTTP MCP): instance_acl.rs::resolve_hides_owner_private_instance_from_non_owner (owner resolves own; non-owner gets absent + no page_id; admin + public resolve). Remaining read-gaps (lower severity, tracked): neighbours (filter edges to owner-private pages), run_stored_query (SQL row-filtering), assign_event (admin-gate). --- crates/escurel-server/src/mcp.rs | 31 +++++++++- crates/escurel-server/tests/instance_acl.rs | 65 +++++++++++++++++++++ 2 files changed, 93 insertions(+), 3 deletions(-) diff --git a/crates/escurel-server/src/mcp.rs b/crates/escurel-server/src/mcp.rs index 977b266..5290d62 100644 --- a/crates/escurel-server/src/mcp.rs +++ b/crates/escurel-server/src/mcp.rs @@ -577,7 +577,7 @@ async fn dispatch_tools_call( match params.name.as_str() { "list_skills" => tool_list_skills(indexer).await, "list_instances" => tool_list_instances(indexer, caller, params.arguments).await, - "resolve" => tool_resolve(indexer, params.arguments).await, + "resolve" => tool_resolve(indexer, caller, params.arguments).await, "expand" => tool_expand(indexer, caller, params.arguments).await, "neighbours" => tool_neighbours(indexer, params.arguments).await, "search" => tool_search(indexer, caller, params.arguments).await, @@ -741,13 +741,38 @@ struct ResolveArgs { scenario: Option, } -async fn tool_resolve(indexer: &Indexer, args: Value) -> Result { +async fn tool_resolve( + indexer: &Indexer, + caller: AclCaller<'_>, + args: Value, +) -> Result { let a: ResolveArgs = serde_json::from_value(args) .map_err(|e| JsonRpcError::invalid_params(format!("resolve: {e}")))?; - let resolved = indexer + let mut resolved = indexer .resolve(&a.wikilink, a.scenario.as_deref()) .await .map_err(|e| JsonRpcError::internal(format!("resolve: {e}")))?; + // ACL (always on, mirroring the read filters): never disclose the + // existence / page_id of an owner-private instance the caller cannot + // read — resolve it to "not found", exactly as `expand` returns null. + if let Some(p) = &resolved.page + && p.page_type == PageType::Instance + { + let readable = match indexer + .expand(&p.page_id, None, None) + .await + .map_err(|e| JsonRpcError::internal(format!("resolve acl: {e}")))? + { + Some(e) => indexer + .may_read_instance(&caller, &p.skill, &e.frontmatter) + .await + .map_err(|e| JsonRpcError::internal(format!("resolve acl: {e}")))?, + None => true, + }; + if !readable { + resolved.page = None; + } + } let exists = resolved.exists(); let parsed = &resolved.parsed; Ok(json!({ diff --git a/crates/escurel-server/tests/instance_acl.rs b/crates/escurel-server/tests/instance_acl.rs index 000f403..5689ba1 100644 --- a/crates/escurel-server/tests/instance_acl.rs +++ b/crates/escurel-server/tests/instance_acl.rs @@ -169,3 +169,68 @@ async fn admin_bypasses_and_public_is_world_readable() { let talk = call(&p, &bob, "expand", json!({ "page_id": KEYNOTE_PAGE })).await; assert!(talk["page"].is_object(), "a public talk is world-readable"); } + +#[tokio::test] +async fn resolve_hides_owner_private_instance_from_non_owner() { + let p = start().await; + let alice = p.mint_token_with_sub(TENANT, Role::Agent, ALICE); + let bob = p.mint_token_with_sub(TENANT, Role::Agent, BOB); + let admin = p.mint_token(TENANT, Role::Admin); + + // Owner resolves her own member → found. + let own = call( + &p, + &alice, + "resolve", + json!({ "wikilink": "[[community_member::alice]]" }), + ) + .await; + assert_eq!( + own["exists"], + json!(true), + "alice resolves her own member: {own}" + ); + + // Non-owner must NOT discover the existence/page_id → resolves to absent. + let other = call( + &p, + &bob, + "resolve", + json!({ "wikilink": "[[community_member::alice]]" }), + ) + .await; + assert_eq!( + other["exists"], + json!(false), + "bob must not resolve alice: {other}" + ); + assert!(other["page"].is_null(), "no page_id leaked to bob: {other}"); + + // Admin resolves anyone. + let as_admin = call( + &p, + &admin, + "resolve", + json!({ "wikilink": "[[community_member::alice]]" }), + ) + .await; + assert_eq!( + as_admin["exists"], + json!(true), + "admin resolves any: {as_admin}" + ); + + // Public instances resolve for anyone. + let talk = call( + &p, + &bob, + "resolve", + json!({ "wikilink": "[[talk::keynote]]" }), + ) + .await; + assert_eq!( + talk["exists"], + json!(true), + "public talk resolves for any member: {talk}" + ); +} From 855c20c4eda7d6eec24a18cfc2d5c9a82ffc9172 Mon Sep 17 00:00:00 2001 From: Joachim Rosskopf Date: Mon, 15 Jun 2026 09:50:47 +0200 Subject: [PATCH 2/2] feat(acl): close the neighbours() read-gap too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit neighbours() returned graph edges to/from a page regardless of whether the OTHER endpoint is an owner-private instance the caller can't read — leaking the existence of links to private records. Filter (always-on): drop an edge whose neighbour endpoint is an owner-private instance the caller can't read. Test: neighbours_filters_edges_to_owner_private_pages (alice sees the edge from her own event_profile; bob sees none). resolve + neighbours now mirror the expand/list/search read ACL. Remaining (lower severity): run_stored_query SQL row-filtering; assign_event admin-gate (deferred pending a caller audit so it doesn't break a member write path). --- crates/escurel-server/src/mcp.rs | 49 ++++++++++++++++----- crates/escurel-server/tests/instance_acl.rs | 35 +++++++++++++++ 2 files changed, 73 insertions(+), 11 deletions(-) diff --git a/crates/escurel-server/src/mcp.rs b/crates/escurel-server/src/mcp.rs index 5290d62..c5e5b67 100644 --- a/crates/escurel-server/src/mcp.rs +++ b/crates/escurel-server/src/mcp.rs @@ -579,7 +579,7 @@ async fn dispatch_tools_call( "list_instances" => tool_list_instances(indexer, caller, params.arguments).await, "resolve" => tool_resolve(indexer, caller, params.arguments).await, "expand" => tool_expand(indexer, caller, params.arguments).await, - "neighbours" => tool_neighbours(indexer, params.arguments).await, + "neighbours" => tool_neighbours(indexer, caller, params.arguments).await, "search" => tool_search(indexer, caller, params.arguments).await, "run_stored_query" => tool_run_stored_query(indexer, params.arguments).await, "validate" => tool_validate(indexer, params.arguments).await, @@ -865,7 +865,11 @@ struct NeighboursArgs { scenario: Option, } -async fn tool_neighbours(indexer: &Indexer, args: Value) -> Result { +async fn tool_neighbours( + indexer: &Indexer, + caller: AclCaller<'_>, + args: Value, +) -> Result { let a: NeighboursArgs = serde_json::from_value(args) .map_err(|e| JsonRpcError::invalid_params(format!("neighbours: {e}")))?; let dir = match a.direction.as_deref().unwrap_or("both") { @@ -888,15 +892,38 @@ async fn tool_neighbours(indexer: &Indexer, args: Value) -> Result>(), - })) + // ACL (always on): drop edges whose OTHER endpoint is an owner-private + // instance the caller can't read — don't reveal links to/from private + // records. The queried page itself is the caller's vantage point. + let mut out = Vec::with_capacity(edges.len()); + for e in &edges { + let neighbour = if e.src_page == a.page_id { + &e.dst_page + } else { + &e.src_page + }; + let readable = match indexer + .expand(neighbour, None, None) + .await + .map_err(|err| JsonRpcError::internal(format!("neighbours acl: {err}")))? + { + Some(ex) if ex.page.page_type == PageType::Instance => indexer + .may_read_instance(&caller, &ex.page.skill, &ex.frontmatter) + .await + .map_err(|err| JsonRpcError::internal(format!("neighbours acl: {err}")))?, + _ => true, // non-instance / absent → not owner-gated + }; + if readable { + out.push(json!({ + "src_page": e.src_page, + "dst_page": e.dst_page, + "link_skill": e.link_skill, + "link_version": e.link_version, + "dst_anchor": e.dst_anchor, + })); + } + } + Ok(json!({ "edges": out })) } #[derive(Deserialize)] diff --git a/crates/escurel-server/tests/instance_acl.rs b/crates/escurel-server/tests/instance_acl.rs index 5689ba1..71523ab 100644 --- a/crates/escurel-server/tests/instance_acl.rs +++ b/crates/escurel-server/tests/instance_acl.rs @@ -234,3 +234,38 @@ async fn resolve_hides_owner_private_instance_from_non_owner() { "public talk resolves for any member: {talk}" ); } + +#[tokio::test] +async fn neighbours_filters_edges_to_owner_private_pages() { + let p = start().await; + let alice = p.mint_token_with_sub(TENANT, Role::Agent, ALICE); + let bob = p.mint_token_with_sub(TENANT, Role::Agent, BOB); + + // alice's member has an IN-edge from her own (owner-private) event_profile + // (`member: [[community_member::alice]]`). + let own = call( + &p, + &alice, + "neighbours", + json!({ "page_id": ALICE_PAGE, "direction": "in" }), + ) + .await; + assert!( + !own["edges"].as_array().unwrap().is_empty(), + "alice sees the edge from her own event_profile: {own}" + ); + + // bob must NOT see edges to/from alice's private event_profile. + let other = call( + &p, + &bob, + "neighbours", + json!({ "page_id": ALICE_PAGE, "direction": "in" }), + ) + .await; + assert_eq!( + other["edges"].as_array().unwrap().len(), + 0, + "bob must not see edges to alice's private records: {other}" + ); +}