fix(server): align stored-query MCP discovery gates

This commit is contained in:
Ragnor Comerford 2026-06-17 20:16:56 +02:00
parent c06343362a
commit 916dc46c0e
No known key found for this signature in database
13 changed files with 392 additions and 80 deletions

View file

@ -13,9 +13,9 @@ use omnigraph_server::queries::{QueryRegistry, RegistrySpec};
use omnigraph_server::{AppState, build_app};
use serde_json::{Value, json};
use support::{
FIND_PERSON_GQ, INVOKE_POLICY_YAML, app_for_loaded_graph_with_auth_tokens,
app_for_loaded_graph_with_auth_tokens_and_policy, app_with_stored_queries, g, graph_path,
init_loaded_graph, json_response,
FIND_PERSON_GQ, INVOKE_POLICY_YAML, POLICY_PROTECTED_READ_YAML,
app_for_loaded_graph_with_auth_tokens, app_for_loaded_graph_with_auth_tokens_and_policy,
app_with_stored_queries, g, graph_path, init_loaded_graph, json_response,
};
/// Build a JSON-RPC POST to `/graphs/default/mcp`. Sets the `Accept` (both
@ -577,6 +577,40 @@ async fn stored_query_tool_folds_docs_and_honors_mcp_annotation() {
assert_ne!(v["result"]["isError"], json!(true), "renamed tool not callable: {v}");
}
#[tokio::test]
async fn list_gate_matches_call_for_fixed_branchless_reads() {
// A protected-only reader. Branch-arg reads (graph_query) relax and show
// (callable on a protected branch). Fixed branchless reads (schema_get) use
// the faithful read(None) gate — which a protected-scope rule denies — so
// schema_get is hidden, matching the omnigraph://schema resource (same
// branchless read). Tool and resource agree by construction.
let (_t, app) = app_for_loaded_graph_with_auth_tokens_and_policy(
&[("act-bruno", "tok")],
POLICY_PROTECTED_READ_YAML,
)
.await;
let (_s, list) =
json_response(&app, mcp_request(Some("tok"), rpc(1, "tools/list", json!({})))).await;
let names = tool_names(&list);
assert!(
names.contains(&"graph_query".to_string()),
"branch-arg read relaxes and shows under protected-only read: {names:?}"
);
assert!(
!names.contains(&"schema_get".to_string()),
"fixed branchless read uses read(None), denied under protected-only → hidden: {names:?}"
);
// resources/list uses the same read(None) gate → empty, matching schema_get.
let (_s, res) =
json_response(&app, mcp_request(Some("tok"), rpc(2, "resources/list", json!({})))).await;
assert!(
res["result"]["resources"].as_array().unwrap().is_empty(),
"resources hidden under protected-only read, consistent with schema_get: {res}"
);
}
#[tokio::test]
async fn per_query_mode_does_not_expose_meta_tools() {
// Below the auto threshold the projection is per-query, so the discovery +

View file

@ -345,28 +345,29 @@ async fn list_queries_returns_only_exposed_with_typed_params() {
}
#[tokio::test(flavor = "multi_thread")]
async fn list_queries_is_read_gated_so_a_non_invoker_can_list() {
// The catalog is read-gated (not invoke_query-gated), so a reader who
// lacks invoke_query still enumerates the exposed queries — the
// documented probe-oracle gap until per-query Cedar filtering lands.
async fn list_queries_requires_invoke_query() {
// The catalog is invoke_query-gated (same authority as invocation and the
// MCP `tools/list` surface): a reader who lacks invoke_query is denied
// listing, while an invoke_query holder lists it.
let (_temp, app) = app_with_stored_queries(
&[("find_person", FIND_PERSON_GQ, true)],
&[("act-noinvoke", "t-noinvoke")],
&[("act-noinvoke", "t-noinvoke"), ("act-invoke", "t-invoke")],
INVOKE_POLICY_YAML,
)
.await;
let (status, body) = json_response(&app, get_request(&g("/queries"), "t-noinvoke")).await;
assert_eq!(status, StatusCode::OK, "read-gated catalog; body: {body}");
// read-only, no invoke_query → 403.
let (status, _body) = json_response(&app, get_request(&g("/queries"), "t-noinvoke")).await;
assert_eq!(status, StatusCode::FORBIDDEN, "catalog listing requires invoke_query");
// invoke_query holder → 200 with the exposed query.
let (status, body) = json_response(&app, get_request(&g("/queries"), "t-invoke")).await;
assert_eq!(status, StatusCode::OK, "invoker lists the catalog; body: {body}");
let names: Vec<&str> = body["queries"]
.as_array()
.unwrap()
.iter()
.map(|q| q["name"].as_str().unwrap())
.collect();
assert!(
names.contains(&"find_person"),
"a reader lists the catalog despite lacking invoke_query: {names:?}"
);
assert!(names.contains(&"find_person"), "invoker sees the exposed query: {names:?}");
}
#[tokio::test(flavor = "multi_thread")]

View file

@ -362,6 +362,10 @@ rules:
actors: {{ group: permitted }}
actions: [schema_apply, branch_create, branch_delete, branch_merge]
target_branch_scope: any
- id: permit-invoke
allow:
actors: {{ group: permitted }}
actions: [invoke_query]
"#
)
}