From a666b8d8a43d7684ba79707e6ce1b18faecd79d6 Mon Sep 17 00:00:00 2001 From: Ashish Kolhe Date: Wed, 28 Oct 2026 09:47:34 +0530 Subject: [PATCH] fix(license): gate public dashboard data behind the licence search gate The rebase onto main brought public dashboards (#14789), whose data endpoint builds results through search_service and promql without any licence check, so an anonymous link kept reading data while search was blocked. The data read now answers 429 when the search gate is closed, warm results included, with the generic rate-limit body so the licence id never reaches an anonymous viewer; the meta org passes as in the search route middleware. The three public dashboard build call sites are classified as helpers behind that check. --- .../handler/http/router/search_gate_tests.rs | 5 ++- .../src/request/public_dashboards/public.rs | 45 +++++++++++++++++++ 2 files changed, 49 insertions(+), 1 deletion(-) 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()); + } }