fix(gateway): internal-op invisibility on /schema + cache headers (SRV-02, PRJ-06, GW-02)

- SRV-02: schema_handler applies the same is_internal_op -> 404 pre-check
  as /call, /batch, /subscribe, /publish (tested unauthenticated,
  anonymous-token, unauthorized-identity)
- PRJ-06: MCP schema tool runs the symmetric pre-check (NOT_FOUND for
  internal, FORBIDDEN for ACL-denied) before dispatch; enshrining test
  fixed
- GW-02: /search + /schema carry Cache-Control: no-store and
  Vary: Authorization on success and error responses

Verification: cargo test 270 passed; --all-features 337+9+6+8+10 passed;
clippy -D warnings clean (default + --all-features); fmt clean.
This commit is contained in:
2026-08-29 11:13:57 +00:00
parent 8700ed0fea
commit 239f11323e
3 changed files with 379 additions and 12 deletions
+184
View File
@@ -28,6 +28,8 @@ use std::sync::Arc;
use alkcall::core::auth::Identity;
use alkcall::protocol::wire::{CallError, ResponseEnvelope};
use alkcall::registry::registration::OperationRegistry;
use alkcall::registry::spec::{AccessResult, Visibility};
use rmcp::model::{
CallToolRequestParams, CallToolResult, Implementation, JsonObject, ListToolsResult,
PaginatedRequestParams, ServerCapabilities, ServerInfo, Tool,
@@ -162,6 +164,11 @@ impl ToMcpGateway {
}));
}
};
if let Some(error) =
schema_visibility_and_access_denial(self.dispatch.registry(), &name, identity.as_ref())
{
return call_error_to_structured_error(error);
}
let response = self
.dispatch
.invoke(
@@ -257,6 +264,28 @@ fn batch_error_value(result: CallToolResult) -> Value {
})
}
/// Symmetric with HTTP `GET /schema` (PRJ-06): internal ops are
/// invisible (NOT_FOUND regardless of caller), ACL-forbidden ops
/// return FORBIDDEN. The MCP `schema` tool must never return the full
/// spec of an op the caller could not call.
fn schema_visibility_and_access_denial(
registry: &OperationRegistry,
operation: &str,
identity: Option<&Identity>,
) -> Option<CallError> {
let name = operation.strip_prefix('/').unwrap_or(operation);
let registration = registry.registration(name)?;
if registration.spec.visibility == Visibility::Internal {
return Some(CallError::not_found(operation));
}
if let AccessResult::Forbidden(message) =
registration.spec.access_control.check(identity, None, None)
{
return Some(CallError::forbidden(message));
}
None
}
fn map_search_response(response: ResponseEnvelope) -> CallToolResult {
match response.result {
Ok(value) => {
@@ -493,6 +522,19 @@ mod tests {
)
}
fn internal_spec(name: &str) -> OperationSpec {
OperationSpec::new(
name,
OperationType::Query,
Visibility::Internal,
serde_json::json!({}),
serde_json::json!({}),
vec![],
AccessControl::default(),
None,
)
}
fn make_echo_handler() -> alkcall::registry::registration::Handler {
make_handler(
|input, context| async move { ResponseEnvelope::ok(context.request_id, input) },
@@ -731,9 +773,151 @@ mod tests {
assert!(structured.get("input_schema").is_some());
assert!(structured.get("output_schema").is_some());
assert!(structured.get("error_schemas").is_some());
}
#[tokio::test]
async fn schema_returns_full_spec_for_authorized_identity() {
let registry = full_registry_with_ops(vec![(
"fs/readFile".to_string(),
OperationType::Query,
AccessControl {
required_scopes: vec!["fs:read".to_string()],
..Default::default()
},
)]);
let gateway = ToMcpGateway::new(dispatch(registry, provider()));
let mut args = Map::new();
args.insert("name".to_string(), Value::String("fs/readFile".to_string()));
let result = invoke_tool(
&gateway,
"schema",
Some(args),
Some(identity_with_scopes("reader", &["fs:read"])),
)
.await;
assert_eq!(result.is_error, Some(false));
let structured = result.structured_content.expect("structured present");
assert_eq!(
structured.get("name"),
Some(&Value::String("fs/readFile".to_string()))
);
assert!(structured.get("access_control").is_some());
}
#[tokio::test]
async fn schema_denies_acl_forbidden_op_symmetrically_with_http() {
let registry = full_registry_with_ops(vec![(
"admin/secret".to_string(),
OperationType::Query,
AccessControl {
required_scopes: vec!["admin".to_string()],
..Default::default()
},
)]);
let gateway = ToMcpGateway::new(dispatch(registry, provider()));
let mut args = Map::new();
args.insert(
"name".to_string(),
Value::String("admin/secret".to_string()),
);
let result = invoke_tool(
&gateway,
"schema",
Some(args),
Some(identity_with_scopes("user", &["user"])),
)
.await;
assert_eq!(result.is_error, Some(true));
let structured = result.structured_content.expect("structured error present");
assert_eq!(
structured.get("code"),
Some(&Value::String("FORBIDDEN".to_string()))
);
assert!(
structured.get("input_schema").is_none(),
"a denied schema lookup must not leak the op's schemas"
);
}
#[tokio::test]
async fn schema_denies_internal_op_with_not_found_symmetrically_with_http() {
let mut inner = OperationRegistry::new();
inner
.register(HandlerRegistration::new(
internal_spec("secret/op"),
HandlerKind::Once(make_echo_handler()),
OperationProvenance::Local,
None,
None,
Capabilities::new(),
))
.unwrap();
let inner = Arc::new(inner);
let mut dispatch_registry = OperationRegistry::new();
dispatch_registry
.register(HandlerRegistration::new(
internal_spec("secret/op"),
HandlerKind::Once(make_echo_handler()),
OperationProvenance::Local,
None,
None,
Capabilities::new(),
))
.unwrap();
dispatch_registry
.register(HandlerRegistration::new(
services_list_spec(),
HandlerKind::Once(services_list_handler(Arc::clone(&inner))),
OperationProvenance::Local,
None,
Some(ScopedPeerEnv::empty()),
Capabilities::new(),
))
.unwrap();
dispatch_registry
.register(HandlerRegistration::new(
services_schema_spec(),
HandlerKind::Once(services_schema_handler(Arc::clone(&inner))),
OperationProvenance::Local,
None,
Some(ScopedPeerEnv::empty()),
Capabilities::new(),
))
.unwrap();
let gateway = ToMcpGateway::new(dispatch(Arc::new(dispatch_registry), provider()));
let mut args = Map::new();
args.insert("name".to_string(), Value::String("secret/op".to_string()));
let unauthenticated = invoke_tool(&gateway, "schema", Some(args.clone()), None).await;
assert_eq!(unauthenticated.is_error, Some(true));
let structured = unauthenticated
.structured_content
.expect("structured error present");
assert_eq!(
structured.get("code"),
Some(&Value::String("NOT_FOUND".to_string()))
);
assert!(structured.get("input_schema").is_none());
let unauthorized = invoke_tool(
&gateway,
"schema",
Some(args),
Some(identity_with_scopes("user", &["user"])),
)
.await;
assert_eq!(unauthorized.is_error, Some(true));
let structured = unauthorized
.structured_content
.expect("structured error present");
assert_eq!(
structured.get("code"),
Some(&Value::String("NOT_FOUND".to_string()))
);
}
#[tokio::test]
async fn call_returns_structured_for_success() {
let registry = full_registry_with_ops(vec![(