diff --git a/src/api/http/src/handler/http/router/search_gate_tests.rs b/src/api/http/src/handler/http/router/search_gate_tests.rs index e6f2ac7bc0..38c15c7d5a 100644 --- a/src/api/http/src/handler/http/router/search_gate_tests.rs +++ b/src/api/http/src/handler/http/router/search_gate_tests.rs @@ -274,7 +274,7 @@ const SEARCH_IMPORT: &str = r"\buse [^;]*\b(?:search|search_service|SearchServic /// Every production fn that calls a search entry point, as (file, fn), and how it is licence-gated. #[rustfmt::skip] -const SEARCH_CALLERS: [(&str, &str, Caller); 62] = [ +const SEARCH_CALLERS: [(&str, &str, Caller); 65] = [ ("api/management/src/request/alert_calibration/digests.rs", "read", Caller::Operational), ("api/management/src/request/alerts/history.rs", "get_alert_history", Caller::Exempt("the org's own alert and anomaly run history; its routes stay open")), ("api/management/src/request/alerts/history.rs", "get_all_anomaly_history", Caller::Exempt("the org's own alert and anomaly run history; its routes stay open")), @@ -326,6 +326,9 @@ const SEARCH_CALLERS: [(&str, &str, Caller); 62] = [ ("core/src/fleet.rs", "search_audit", Caller::Exempt("fleet audit trail from the meta org audit stream; its route stays open")), ("core/src/metrics/prom.rs", "get_label_values", Caller::Routed("promql::label_values")), ("core/src/metrics/prom.rs", "get_series", Caller::Routed("promql::label_values")), + ("core/src/public_dashboards/mod.rs", "promql", Caller::Helper("public dashboard builds; request/public_dashboards/public.rs checks the licence before any read")), + ("core/src/public_dashboards/mod.rs", "publish_estimate", Caller::Helper("public dashboard builds; request/public_dashboards/public.rs checks the licence before any read")), + ("core/src/public_dashboards/mod.rs", "sql", Caller::Helper("public dashboard builds; request/public_dashboards/public.rs checks the licence before any read")), ("core/src/self_reporting/search.rs", "get_usage", Caller::Exempt("licence usage from the meta org")), ("core/src/slo/job.rs", "prom_search", Caller::Background("SLO job")), ("core/src/slo/job.rs", "search", Caller::Background("SLO job")), diff --git a/src/api/management/src/request/public_dashboards/public.rs b/src/api/management/src/request/public_dashboards/public.rs index c0992f8ec4..02d4fc0846 100644 --- a/src/api/management/src/request/public_dashboards/public.rs +++ b/src/api/management/src/request/public_dashboards/public.rs @@ -264,6 +264,9 @@ async fn serve_data_on( Servable::NotFound => return StatusCode::NOT_FOUND.into_response(), Servable::Error => return server_error(), }; + if let Some(refused) = search_refused(&pd.org_id) { + return refused; + } let range_key = range_key.map_or_else(|| default_range_key(&pd), str::to_string); let Some(range) = offered_range(&pd, &range_key) else { // A removed range's file goes on a later ask; a made-up key costs only a read lock. @@ -443,6 +446,17 @@ fn server_error() -> Response { (StatusCode::INTERNAL_SERVER_ERROR, "Something went wrong.").into_response() } +/// A closed licence search gate serves the viewer nothing, warm results included; meta passes. +fn search_refused(org: &str) -> Option { + if org == config::META_ORG_ID { + return None; + } + // The gate's own message names the licence id, which an anonymous viewer must not see. + search_service::check_search_allowed(org, None) + .err() + .map(|_| too_many_requests(READ_WINDOW)) +} + fn unavailable() -> Response { ( StatusCode::SERVICE_UNAVAILABLE, @@ -1129,4 +1143,35 @@ mod tests { let resp = serve_data_on(&primary, slug, Some("../r60"), None).await; assert_eq!(resp.status(), StatusCode::NOT_FOUND); } + + #[cfg(feature = "enterprise")] + #[tokio::test] + async fn a_closed_search_gate_refuses_the_public_data_read_with_429() { + use o2_enterprise::enterprise::license::close_search_gate_for_tests; + + let slug = "Lc8Zt2mR7aB3cD4eF5gH6Q"; + let live = Model { + id: config::ider::uuid(), + slug: slug.into(), + time_ranges: Some(r#"[{"type":"relative","secs":3600}]"#.into()), + default_range_key: Some("r3600".into()), + ..link() + }; + let primary = sqlite().await; + table::insert_capped(&primary, &live, 50, 10, false) + .await + .unwrap(); + let _gate = close_search_gate_for_tests().await; + for etag in [None, Some("\"r1\"")] { + let resp = serve_data_on(&primary, slug, Some("r3600"), etag).await; + assert_eq!(resp.status(), StatusCode::TOO_MANY_REQUESTS); + assert_eq!(resp.headers()[header::RETRY_AFTER], "60"); + let body = axum::body::to_bytes(resp.into_body(), usize::MAX) + .await + .unwrap(); + let body = String::from_utf8_lossy(&body); + assert!(!body.contains("License ID"), "{body}"); + } + assert!(search_refused(config::META_ORG_ID).is_none()); + } }