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:
@@ -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![(
|
||||
|
||||
Reference in New Issue
Block a user