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:
+152
-4
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user