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
+152 -4
View File
@@ -19,7 +19,8 @@ use alkcall::registry::registration::OperationRegistry;
use alkcall::registry::spec::{AccessResult, Visibility};
use axum::body::Bytes;
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::{IntoResponse, Json, Response, Sse};
use axum::routing::{get, post};
@@ -127,7 +128,7 @@ pub(crate) async fn search_handler(
let envelope = dispatch
.invoke(identity.clone(), SERVICES_LIST, json!({}))
.await;
envelope_to_response(envelope, identity.as_ref())
discovery_get_response(envelope, identity.as_ref())
}
pub(crate) async fn schema_handler(
@@ -135,8 +136,11 @@ pub(crate) async fn schema_handler(
ResolvedIdentity(identity): ResolvedIdentity,
Query(query): Query<SchemaQuery>,
) -> 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()) {
return forbidden_response(forbidden, identity.as_ref());
return with_no_cache_headers(forbidden_response(forbidden, identity.as_ref()));
}
let dispatch = state.dispatch();
let envelope = dispatch
@@ -146,7 +150,7 @@ pub(crate) async fn schema_handler(
json!({ "name": query.name }),
)
.await;
envelope_to_response(envelope, identity.as_ref())
discovery_get_response(envelope, identity.as_ref())
}
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 {
match envelope.result {
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")));
}
#[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]
async fn batch_returns_array_of_results_in_order() {
let router = build_router(registry_with_echo(), unused_provider());
@@ -1615,6 +1680,89 @@ mod tests {
let resp = router.oneshot(req).await.unwrap();
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) -------------------------------------------------
use alkcall::registry::registration::make_sink_handler;