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.
This commit is contained in:
Ashish Kolhe
2026-11-04 20:18:58 +05:30
parent b8fc17d241
commit a666b8d8a4
2 changed files with 49 additions and 1 deletions
@@ -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")),
@@ -264,6 +264,9 @@ async fn serve_data_on<C: ConnectionTrait>(
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<Response> {
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());
}
}