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::core::auth::Identity;
|
||||||
use alkcall::protocol::wire::{CallError, ResponseEnvelope};
|
use alkcall::protocol::wire::{CallError, ResponseEnvelope};
|
||||||
|
use alkcall::registry::registration::OperationRegistry;
|
||||||
|
use alkcall::registry::spec::{AccessResult, Visibility};
|
||||||
use rmcp::model::{
|
use rmcp::model::{
|
||||||
CallToolRequestParams, CallToolResult, Implementation, JsonObject, ListToolsResult,
|
CallToolRequestParams, CallToolResult, Implementation, JsonObject, ListToolsResult,
|
||||||
PaginatedRequestParams, ServerCapabilities, ServerInfo, Tool,
|
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
|
let response = self
|
||||||
.dispatch
|
.dispatch
|
||||||
.invoke(
|
.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 {
|
fn map_search_response(response: ResponseEnvelope) -> CallToolResult {
|
||||||
match response.result {
|
match response.result {
|
||||||
Ok(value) => {
|
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 {
|
fn make_echo_handler() -> alkcall::registry::registration::Handler {
|
||||||
make_handler(
|
make_handler(
|
||||||
|input, context| async move { ResponseEnvelope::ok(context.request_id, input) },
|
|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("input_schema").is_some());
|
||||||
assert!(structured.get("output_schema").is_some());
|
assert!(structured.get("output_schema").is_some());
|
||||||
assert!(structured.get("error_schemas").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());
|
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]
|
#[tokio::test]
|
||||||
async fn call_returns_structured_for_success() {
|
async fn call_returns_structured_for_success() {
|
||||||
let registry = full_registry_with_ops(vec![(
|
let registry = full_registry_with_ops(vec![(
|
||||||
|
|||||||
+152
-4
@@ -19,7 +19,8 @@ use alkcall::registry::registration::OperationRegistry;
|
|||||||
use alkcall::registry::spec::{AccessResult, Visibility};
|
use alkcall::registry::spec::{AccessResult, Visibility};
|
||||||
use axum::body::Bytes;
|
use axum::body::Bytes;
|
||||||
use axum::extract::{FromRef, Query, State};
|
use axum::extract::{FromRef, Query, State};
|
||||||
use axum::http::StatusCode;
|
use axum::http::header::{CACHE_CONTROL, VARY};
|
||||||
|
use axum::http::{HeaderValue, StatusCode};
|
||||||
use axum::response::sse::{Event, KeepAlive};
|
use axum::response::sse::{Event, KeepAlive};
|
||||||
use axum::response::{IntoResponse, Json, Response, Sse};
|
use axum::response::{IntoResponse, Json, Response, Sse};
|
||||||
use axum::routing::{get, post};
|
use axum::routing::{get, post};
|
||||||
@@ -127,7 +128,7 @@ pub(crate) async fn search_handler(
|
|||||||
let envelope = dispatch
|
let envelope = dispatch
|
||||||
.invoke(identity.clone(), SERVICES_LIST, json!({}))
|
.invoke(identity.clone(), SERVICES_LIST, json!({}))
|
||||||
.await;
|
.await;
|
||||||
envelope_to_response(envelope, identity.as_ref())
|
discovery_get_response(envelope, identity.as_ref())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) async fn schema_handler(
|
pub(crate) async fn schema_handler(
|
||||||
@@ -135,8 +136,11 @@ pub(crate) async fn schema_handler(
|
|||||||
ResolvedIdentity(identity): ResolvedIdentity,
|
ResolvedIdentity(identity): ResolvedIdentity,
|
||||||
Query(query): Query<SchemaQuery>,
|
Query(query): Query<SchemaQuery>,
|
||||||
) -> Response {
|
) -> Response {
|
||||||
|
if is_internal_op(&state.registry, &query.name) {
|
||||||
|
return with_no_cache_headers(not_found_response(&query.name));
|
||||||
|
}
|
||||||
if let Some(forbidden) = access_check_for_op(&state.registry, &query.name, identity.as_ref()) {
|
if let Some(forbidden) = access_check_for_op(&state.registry, &query.name, identity.as_ref()) {
|
||||||
return forbidden_response(forbidden, identity.as_ref());
|
return with_no_cache_headers(forbidden_response(forbidden, identity.as_ref()));
|
||||||
}
|
}
|
||||||
let dispatch = state.dispatch();
|
let dispatch = state.dispatch();
|
||||||
let envelope = dispatch
|
let envelope = dispatch
|
||||||
@@ -146,7 +150,7 @@ pub(crate) async fn schema_handler(
|
|||||||
json!({ "name": query.name }),
|
json!({ "name": query.name }),
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
envelope_to_response(envelope, identity.as_ref())
|
discovery_get_response(envelope, identity.as_ref())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) async fn batch_handler(
|
pub(crate) async fn batch_handler(
|
||||||
@@ -501,6 +505,22 @@ fn envelope_to_response(envelope: ResponseEnvelope, identity: Option<&Identity>)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The per-identity GET endpoints (`/search`, `/schema`) are
|
||||||
|
/// AccessControl-filtered per caller and auth-dependent (200 vs 403/404
|
||||||
|
/// on the same name), so no shared cache may store or reuse the
|
||||||
|
/// response (GW-02). The header pair applies to every response these
|
||||||
|
/// routes emit, including the denial (error) paths.
|
||||||
|
fn discovery_get_response(envelope: ResponseEnvelope, identity: Option<&Identity>) -> Response {
|
||||||
|
with_no_cache_headers(envelope_to_response(envelope, identity))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn with_no_cache_headers(mut response: Response) -> Response {
|
||||||
|
let headers = response.headers_mut();
|
||||||
|
headers.insert(CACHE_CONTROL, HeaderValue::from_static("no-store"));
|
||||||
|
headers.insert(VARY, HeaderValue::from_static("Authorization"));
|
||||||
|
response
|
||||||
|
}
|
||||||
|
|
||||||
fn envelope_to_json(envelope: ResponseEnvelope) -> Value {
|
fn envelope_to_json(envelope: ResponseEnvelope) -> Value {
|
||||||
match envelope.result {
|
match envelope.result {
|
||||||
Ok(output) => envelope_to_ok_json(&envelope.request_id, &output),
|
Ok(output) => envelope_to_ok_json(&envelope.request_id, &output),
|
||||||
@@ -1071,6 +1091,51 @@ mod tests {
|
|||||||
assert_eq!(body.get("code"), Some(&json!("NOT_FOUND")));
|
assert_eq!(body.get("code"), Some(&json!("NOT_FOUND")));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn schema_internal_op_returns_404_unauthenticated() {
|
||||||
|
let router = build_router(registry_with_internal_op(), unused_provider());
|
||||||
|
let req = Request::builder()
|
||||||
|
.method("GET")
|
||||||
|
.uri("/schema?name=secret%2Fop")
|
||||||
|
.body(Body::empty())
|
||||||
|
.unwrap();
|
||||||
|
let (status, body) = send(router, req).await;
|
||||||
|
assert_eq!(status, StatusCode::NOT_FOUND);
|
||||||
|
assert_eq!(body.get("code"), Some(&json!("NOT_FOUND")));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn schema_internal_op_returns_404_for_unauthorized_identity() {
|
||||||
|
let provider: Arc<dyn IdentityProvider> = Arc::new(
|
||||||
|
StaticIdentityProvider::new()
|
||||||
|
.with_token("user-tok", identity_with_scopes("user", &["user"])),
|
||||||
|
);
|
||||||
|
let router = build_router(registry_with_internal_op(), provider);
|
||||||
|
let (k, v) = auth_header("user-tok");
|
||||||
|
let req = Request::builder()
|
||||||
|
.method("GET")
|
||||||
|
.uri("/schema?name=secret%2Fop")
|
||||||
|
.header(k, v)
|
||||||
|
.body(Body::empty())
|
||||||
|
.unwrap();
|
||||||
|
let (status, _body) = send(router, req).await;
|
||||||
|
assert_eq!(status, StatusCode::NOT_FOUND);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn schema_internal_op_returns_404_for_anonymous_identity() {
|
||||||
|
let router = build_router(registry_with_internal_op(), unused_provider());
|
||||||
|
let (k, v) = auth_header("unknown-tok");
|
||||||
|
let req = Request::builder()
|
||||||
|
.method("GET")
|
||||||
|
.uri("/schema?name=secret%2Fop")
|
||||||
|
.header(k, v)
|
||||||
|
.body(Body::empty())
|
||||||
|
.unwrap();
|
||||||
|
let (status, _body) = send(router, req).await;
|
||||||
|
assert_eq!(status, StatusCode::NOT_FOUND);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn batch_returns_array_of_results_in_order() {
|
async fn batch_returns_array_of_results_in_order() {
|
||||||
let router = build_router(registry_with_echo(), unused_provider());
|
let router = build_router(registry_with_echo(), unused_provider());
|
||||||
@@ -1615,6 +1680,89 @@ mod tests {
|
|||||||
let resp = router.oneshot(req).await.unwrap();
|
let resp = router.oneshot(req).await.unwrap();
|
||||||
assert_eq!(resp.status(), StatusCode::OK);
|
assert_eq!(resp.status(), StatusCode::OK);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn search_and_schema_carry_no_store_and_vary_authorization() {
|
||||||
|
let discovery = registry_with_discovery_and_ops(vec![HandlerRegistration::new(
|
||||||
|
external_spec("echo/run", AccessControl::default()),
|
||||||
|
HandlerKind::Once(echo_handler()),
|
||||||
|
OperationProvenance::Local,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
Capabilities::new(),
|
||||||
|
)]);
|
||||||
|
let router = build_router(discovery, unused_provider());
|
||||||
|
for uri in ["/search", "/schema?name=echo%2Frun"] {
|
||||||
|
let req = Request::builder()
|
||||||
|
.method("GET")
|
||||||
|
.uri(uri)
|
||||||
|
.body(Body::empty())
|
||||||
|
.unwrap();
|
||||||
|
let resp = router.clone().oneshot(req).await.unwrap();
|
||||||
|
assert_eq!(resp.status(), StatusCode::OK);
|
||||||
|
let cache_control = resp
|
||||||
|
.headers()
|
||||||
|
.get(axum::http::header::CACHE_CONTROL)
|
||||||
|
.map(|v| v.to_str().unwrap().to_string());
|
||||||
|
assert_eq!(
|
||||||
|
cache_control.as_deref(),
|
||||||
|
Some("no-store"),
|
||||||
|
"GET {uri} must not be cacheable (GW-02)"
|
||||||
|
);
|
||||||
|
let vary = resp
|
||||||
|
.headers()
|
||||||
|
.get(axum::http::header::VARY)
|
||||||
|
.map(|v| v.to_str().unwrap().to_string());
|
||||||
|
assert_eq!(
|
||||||
|
vary.as_deref(),
|
||||||
|
Some("Authorization"),
|
||||||
|
"GET {uri} is per-identity; it must Vary on Authorization (GW-02)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn schema_denials_carry_no_store_and_vary_authorization() {
|
||||||
|
let ops = vec![HandlerRegistration::new(
|
||||||
|
external_spec(
|
||||||
|
"admin/secret",
|
||||||
|
AccessControl {
|
||||||
|
required_scopes: vec!["admin".to_string()],
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
),
|
||||||
|
HandlerKind::Once(echo_handler()),
|
||||||
|
OperationProvenance::Local,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
Capabilities::new(),
|
||||||
|
)];
|
||||||
|
let discovery = registry_with_discovery_and_ops(ops);
|
||||||
|
let router = build_router(discovery, unused_provider());
|
||||||
|
let req = Request::builder()
|
||||||
|
.method("GET")
|
||||||
|
.uri("/schema?name=admin%2Fsecret")
|
||||||
|
.body(Body::empty())
|
||||||
|
.unwrap();
|
||||||
|
let resp = router.oneshot(req).await.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
resp.status(),
|
||||||
|
StatusCode::UNAUTHORIZED,
|
||||||
|
"FORBIDDEN with no identity maps to 401 (gateway error mapping)"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
resp.headers()
|
||||||
|
.get(axum::http::header::CACHE_CONTROL)
|
||||||
|
.map(|v| v.to_str().unwrap()),
|
||||||
|
Some("no-store")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
resp.headers()
|
||||||
|
.get(axum::http::header::VARY)
|
||||||
|
.map(|v| v.to_str().unwrap()),
|
||||||
|
Some("Authorization")
|
||||||
|
);
|
||||||
|
}
|
||||||
// --- /publish (ADR-068) -------------------------------------------------
|
// --- /publish (ADR-068) -------------------------------------------------
|
||||||
|
|
||||||
use alkcall::registry::registration::make_sink_handler;
|
use alkcall::registry::registration::make_sink_handler;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
id: review-001-schema-internal-visibility
|
id: review-001-schema-internal-visibility
|
||||||
name: Enforce Internal-op invisibility on GET /schema (SRV-02, PRJ-06, GW-02)
|
name: Enforce Internal-op invisibility on GET /schema (SRV-02, PRJ-06, GW-02)
|
||||||
status: pending
|
status: completed
|
||||||
depends_on: []
|
depends_on: []
|
||||||
scope: narrow
|
scope: narrow
|
||||||
risk: medium
|
risk: medium
|
||||||
@@ -39,11 +39,11 @@ to the two per-identity GETs.
|
|||||||
|
|
||||||
## Acceptance Criteria
|
## Acceptance Criteria
|
||||||
|
|
||||||
- [ ] `GET /schema?name=<internal>` → 404 unauthenticated, for an unauthorized identity, and for an anonymous identity (test)
|
- [x] `GET /schema?name=<internal>` → 404 unauthenticated, for an unauthorized identity, and for an anonymous identity (test)
|
||||||
- [ ] MCP `schema` tool denies/404s unauthorized ops symmetrically with HTTP `/schema` (test); enshrining test fixed
|
- [x] MCP `schema` tool denies/404s unauthorized ops symmetrically with HTTP `/schema` (test); enshrining test fixed
|
||||||
- [ ] `/search` + `/schema` responses carry `Cache-Control: no-store` (and `Vary: Authorization` where a token can change the body)
|
- [x] `/search` + `/schema` responses carry `Cache-Control: no-store` (and `Vary: Authorization` where a token can change the body)
|
||||||
- [ ] `cargo test` and `cargo clippy --all-targets -- -D warnings` pass
|
- [x] `cargo test` and `cargo clippy --all-targets -- -D warnings` pass
|
||||||
- [ ] `cargo test --all-features` passes (the MCP half is feature-gated)
|
- [x] `cargo test --all-features` passes (the MCP half is feature-gated)
|
||||||
|
|
||||||
## References
|
## References
|
||||||
|
|
||||||
@@ -53,8 +53,43 @@ to the two per-identity GETs.
|
|||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
|
|
||||||
> Agent fills during implementation.
|
- GW-02 headers are applied to **all** responses the two GET routes emit
|
||||||
|
(200 and denial paths alike) — a 401/403/404 response is equally
|
||||||
|
caller-specific and must not be cached.
|
||||||
|
- `with_no_cache_headers` is a small helper applied inside
|
||||||
|
`schema_handler`'s pre-check returns plus a `discovery_get_response`
|
||||||
|
wrapper on the dispatch path; `/call`, `/batch`, `/subscribe`,
|
||||||
|
`/publish` (non-GET, non-per-identity-response) are untouched.
|
||||||
|
- The `/schema` 404 guard mirrors `call_handler` exactly: `is_internal_op`
|
||||||
|
pre-check before `access_check_for_op`, same `not_found_response`
|
||||||
|
shape, so a caller cannot distinguish internal from unknown ops.
|
||||||
|
- MCP `schema` uses the registry-backed guard
|
||||||
|
(`schema_visibility_and_access_denial`) rather than intercepting the
|
||||||
|
`services/schema` dispatch — symmetric with HTTP (NOT_FOUND for
|
||||||
|
Internal, FORBIDDEN for ACL) and it never reaches the discovery
|
||||||
|
handler for forbidden names.
|
||||||
|
- Notable: `registry.list_operations()` returns External-only and the
|
||||||
|
test fixture built from it drops Internal ops from the dispatch
|
||||||
|
registry — the new tests use the direct-registration fixture
|
||||||
|
(`registry_with_internal_op`), mirroring `call_internal_op_returns_404`.
|
||||||
|
|
||||||
## Summary
|
## Summary
|
||||||
|
|
||||||
> Filled on completion.
|
Fixed the three faces of the Internal-op invisibility gap (review-001
|
||||||
|
SRV-02, PRJ-06, GW-02):
|
||||||
|
|
||||||
|
- SRV-02: `schema_handler` now applies the same `is_internal_op` → 404
|
||||||
|
pre-check as `/call`, `/batch`, `/subscribe`, `/publish`; tests cover
|
||||||
|
unauthenticated, anonymous-token, and unauthorized-identity callers.
|
||||||
|
- PRJ-06: the MCP `schema` tool runs the symmetric pre-check
|
||||||
|
(`NOT_FOUND` for `Visibility::Internal`, `FORBIDDEN` for ACL-denied)
|
||||||
|
before dispatching to `services/schema`; the enshrining
|
||||||
|
`schema_returns_full_operation_spec` test no longer fetches with no
|
||||||
|
identity and asserts `access_control` presence (spec now fetched for
|
||||||
|
an unrestricted op, `access_control` asserted only via the new
|
||||||
|
authorized-identity test).
|
||||||
|
- GW-02: `/search` + `/schema` responses carry `Cache-Control: no-store`
|
||||||
|
and `Vary: Authorization` on both success and error paths.
|
||||||
|
|
||||||
|
Verification: cargo test 270 passed; cargo test --all-features 337+9+6+8+10
|
||||||
|
passed; clippy -D warnings clean (default and --all-features); fmt clean.
|
||||||
Reference in New Issue
Block a user