feat(license): licence-gate the search routes that skipped the search check

This commit is contained in:
Ashish Kolhe
2026-11-04 20:18:56 +05:30
parent d1a120edea
commit 49acdef16b
7 changed files with 942 additions and 103 deletions
Generated
+1
View File
@@ -8139,6 +8139,7 @@ dependencies = [
"reqwest 0.13.4",
"sea-orm",
"search",
"search_service",
"serde",
"serde_json",
"snap",
+3
View File
@@ -21,6 +21,7 @@ enterprise = [
"openobserve-api-search/enterprise",
"openobserve-api-common/enterprise",
"openobserve-core/enterprise",
"search_service/enterprise",
"stream/enterprise",
]
cloud = [
@@ -54,6 +55,7 @@ openobserve-tls.workspace = true
ingestion-common.workspace = true
stream.workspace = true
search.workspace = true
search_service.workspace = true
openobserve-api-ingest.workspace = true
openobserve-api-management.workspace = true
openobserve-api-pipelines.workspace = true
@@ -98,6 +100,7 @@ futures-util.workspace = true
base64.workspace = true
sea-orm.workspace = true
svix-ksuid.workspace = true
o2_enterprise = { workspace = true, default-features = false, features = ["test-utils"] }
tracing.workspace = true
tracing-subscriber.workspace = true
zstd.workspace = true
@@ -17,8 +17,10 @@ mod ingest_rejections;
mod org_blocking;
mod password_policy;
mod root_only;
mod search_license;
pub use ingest_rejections::ingest_rejections_middleware;
pub use org_blocking::{blocked_org_param, blocked_orgs_middleware};
pub use password_policy::password_policy_middleware;
pub use root_only::root_only_middleware;
pub use search_license::search_license_middleware;
@@ -0,0 +1,188 @@
// Copyright 2026 OpenObserve Inc.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use axum::{
extract::{FromRequestParts, RawPathParams, Request},
http::request::Parts,
middleware::Next,
response::Response,
};
use common::meta::http::HttpResponse as MetaHttpResponse;
use config::{META_ORG_ID, utils::base64};
/// Answers the search handlers' licence 429 before a gated route reads any data.
pub async fn search_license_middleware(request: Request, next: Next) -> Response {
let (mut parts, body) = request.into_parts();
if let Some(refused) = refusal(&mut parts).await {
return refused;
}
next.run(Request::from_parts(parts, body)).await
}
async fn refusal(parts: &mut Parts) -> Option<Response> {
// Unreadable params fall through to the closed-gate answer for an unknown org and stream.
let (org_id, streams) = match RawPathParams::from_request_parts(parts, &()).await {
Ok(params) => (
param(&params, "org_id").unwrap_or_default().to_string(),
path_streams(&params),
),
Err(_) => (String::new(), Vec::new()),
};
refusal_for(&org_id, &streams, search_service::check_search_allowed)
}
fn param<'a>(params: &'a RawPathParams, name: &str) -> Option<&'a str> {
params
.iter()
.find_map(|(key, value)| (key == name).then_some(value))
}
fn path_streams(params: &RawPathParams) -> Vec<String> {
if let Some(stream) = param(params, "stream_name") {
return vec![stream.to_string()];
}
param(params, "stream_names")
.and_then(|encoded| base64::decode_url(encoded).ok())
.map(|names| names.split(',').map(str::to_string).collect())
.unwrap_or_default()
}
fn refusal_for(
org_id: &str,
streams: &[String],
gate: impl Fn(&str, Option<&str>) -> Result<(), infra::errors::Error>,
) -> Option<Response> {
if org_id == META_ORG_ID {
return None;
}
let verdict = if streams.is_empty() {
gate(org_id, None)
} else {
streams
.iter()
.try_for_each(|stream| gate(org_id, Some(stream)))
};
verdict.err().map(MetaHttpResponse::too_many_requests)
}
#[cfg(test)]
mod tests {
use std::sync::atomic::{AtomicUsize, Ordering};
use axum::{Router, body::Body, http::StatusCode, routing::get};
use infra::errors::Error;
use tower::ServiceExt;
use super::*;
const BLOCKED: &str = "Search is temporarily disabled due to exceeding allotted ingestion limit. Please contact your administrator.";
fn open(_: &str, _: Option<&str>) -> Result<(), Error> {
Ok(())
}
fn closed(_: &str, _: Option<&str>) -> Result<(), Error> {
Err(Error::Message(BLOCKED.to_string()))
}
fn names(streams: &[&str]) -> Vec<String> {
streams.iter().map(|s| s.to_string()).collect()
}
async fn body_bytes(response: Response) -> Vec<u8> {
axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap()
.to_vec()
}
async fn streams_at(encoded: &str) -> String {
let app = Router::new().route(
"/{org_id}/{stream_names}/_around_multi",
get(|params: RawPathParams| async move { path_streams(&params).join("|") }),
);
let request = axum::http::Request::get(format!("/acme/{encoded}/_around_multi"))
.body(Body::empty())
.unwrap();
String::from_utf8(body_bytes(app.oneshot(request).await.unwrap()).await).unwrap()
}
#[test]
fn open_gate_refuses_nothing() {
assert!(refusal_for("acme", &[], open).is_none());
assert!(refusal_for("acme", &names(&["logs"]), open).is_none());
}
#[tokio::test]
async fn closed_gate_answers_the_search_handler_429() {
let refused = refusal_for("acme", &[], closed).unwrap();
assert_eq!(refused.status(), StatusCode::TOO_MANY_REQUESTS);
assert!(refused.headers().get("retry-after").is_none());
let body = body_bytes(refused).await;
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(
json,
serde_json::json!({"code": 429, "message": format!("Error# {BLOCKED}")})
);
let handler_body = body_bytes(MetaHttpResponse::too_many_requests(Error::Message(
BLOCKED.to_string(),
)))
.await;
assert_eq!(body, handler_body);
}
#[test]
fn meta_org_is_never_refused_and_never_asks_the_gate() {
let calls = AtomicUsize::new(0);
let counting = |org: &str, stream: Option<&str>| {
calls.fetch_add(1, Ordering::SeqCst);
closed(org, stream)
};
assert!(refusal_for(META_ORG_ID, &names(&["logs"]), counting).is_none());
assert!(refusal_for(META_ORG_ID, &[], counting).is_none());
assert_eq!(calls.load(Ordering::SeqCst), 0);
}
#[test]
fn each_path_stream_reaches_the_gate() {
let only_triggers = |_: &str, stream: Option<&str>| match stream {
Some("triggers") => Ok(()),
_ => Err(Error::Message(BLOCKED.to_string())),
};
assert!(refusal_for("acme", &names(&["triggers"]), only_triggers).is_none());
assert!(refusal_for("acme", &names(&["logs"]), only_triggers).is_some());
assert!(refusal_for("acme", &names(&["triggers", "logs"]), only_triggers).is_some());
}
#[tokio::test]
async fn around_multi_stream_names_are_decoded() {
assert_eq!(
streams_at(&base64::encode_url("triggers,logs")).await,
"triggers|logs"
);
}
#[tokio::test]
async fn undecodable_stream_names_are_gated_as_unknown() {
assert_eq!(streams_at("!!not-base64!!").await, "");
let asked = std::sync::Mutex::new(Vec::new());
let recording = |_: &str, stream: Option<&str>| {
asked.lock().unwrap().push(stream.map(str::to_string));
closed("", None)
};
assert!(refusal_for("acme", &[], recording).is_some());
assert_eq!(*asked.lock().unwrap(), vec![None]);
}
}
+111 -98
View File
@@ -85,7 +85,7 @@ use crate::{
},
router::middlewares::{
blocked_org_param, blocked_orgs_middleware, ingest_rejections_middleware,
password_policy_middleware, root_only_middleware,
password_policy_middleware, root_only_middleware, search_license_middleware,
},
},
};
@@ -99,6 +99,8 @@ mod one_ack_tests;
pub mod openapi;
#[cfg(test)]
mod openapi_coverage;
#[cfg(test)]
mod search_gate_tests;
use common::meta::http::ERROR_HEADER;
@@ -1292,9 +1294,13 @@ pub fn service_routes() -> Router {
#[cfg(not(feature = "enterprise"))]
let server = cfg.common.instance_name_short.to_string();
// Downtimes, Slack and service streams live in their own tables to keep this function under the line limit.
let mut router =
public_dashboard_admin_routes(Router::new().merge(downtime_routes()).merge(slack_routes()));
// Downtimes, Slack, search and service streams live in their own tables to keep this function under the line limit.
let mut router = public_dashboard_admin_routes(
Router::new()
.merge(downtime_routes())
.merge(slack_routes())
.merge(search_routes()),
);
router = router.merge(service_streams_routes());
// Full UI configuration — authenticated counterpart of the unauthenticated
// `/config` bootstrap in config_routes()
@@ -1402,7 +1408,6 @@ pub fn service_routes() -> Router {
.route("/{org_id}/{stream_name}/traces/session/details", get(traces::session::get_session_details))
.route("/{org_id}/{stream_name}/traces/user", get(traces::user::get_latest_users))
.route("/{org_id}/{stream_name}/traces/time_range", get(traces::time_index::get_trace_time_range))
.route("/{org_id}/{stream_name}/traces/{trace_id}/details", get(traces::details::get_trace_details))
.route("/{org_id}/{stream_name}/traces/{trace_id}/dag", get(traces::dag::get_trace_dag))
// Profiles query (ingest remains under /v1/profiles)
@@ -1412,43 +1417,6 @@ pub fn service_routes() -> Router {
.route("/{org_id}/{stream_name}/profiles/merge", get(profiles_query::merge_profiles_get).post(profiles_query::merge_profiles))
.route("/{org_id}/{stream_name}/profiles/diff", post(profiles_query::diff_profiles))
// Database Monitoring — its own top-level module, not under /traces.
// The path segment must stay `db_monitoring` to match the OFGA resource
// these routes authorize against (`db_monitoring:{org}`, see o2_openfga
// ROUTE_PERMISSIONS).
//
// Every route below is registered in both builds; the build-type gate
// lives in the handlers, three of which (deadlocks/blocking/
// table_health) are dual-implemented and answer 403 on OSS. Registering
// them in the enterprise-gated block instead would 404 them, losing the
// distinction between "not licensed" and "no such endpoint". Runtime
// off-switch is ZO_DB_MONITORING_ENABLED.
.route("/{org_id}/db_monitoring/databases", get(db_monitoring::handler::get_dbm_databases))
.route("/{org_id}/db_monitoring/queries", get(db_monitoring::handler::get_dbm_queries))
.route("/{org_id}/db_monitoring/query/history", get(db_monitoring::handler::get_dbm_query_history))
.route("/{org_id}/db_monitoring/query/endpoints", get(db_monitoring::handler::get_dbm_query_endpoints))
.route("/{org_id}/db_monitoring/samples", get(db_monitoring::handler::get_dbm_samples))
// Server-vantage events (read the canonical o2_dbm_* columns)
.route("/{org_id}/db_monitoring/deadlocks", get(db_monitoring::handler::get_dbm_deadlocks))
.route("/{org_id}/db_monitoring/blocking", get(db_monitoring::handler::get_dbm_blocking))
.route("/{org_id}/db_monitoring/activity", get(db_monitoring::handler::get_dbm_activity))
// `query/insights` returns the query-detail page's Logs-side pair in one
// round trip; `query/plans` and `query/server_metrics` are superseded by
// it (same sections, same envelopes) and stay registered for
// compatibility.
.route("/{org_id}/db_monitoring/query/insights", get(db_monitoring::handler::get_dbm_query_insights))
.route("/{org_id}/db_monitoring/query/plans", get(db_monitoring::handler::get_dbm_query_plans))
.route("/{org_id}/db_monitoring/query/server_metrics", get(db_monitoring::handler::get_dbm_query_server_metrics))
.route("/{org_id}/db_monitoring/server_queries", get(db_monitoring::handler::get_dbm_server_queries))
.route("/{org_id}/db_monitoring/server_samples", get(db_monitoring::handler::get_dbm_server_samples))
.route("/{org_id}/db_monitoring/table_health", get(db_monitoring::handler::get_dbm_table_health))
.route("/{org_id}/db_monitoring/instances", get(db_monitoring::handler::get_dbm_instances))
.route(
"/{org_id}/db_monitoring/instance_metrics",
get(db_monitoring::handler::get_dbm_instance_metrics),
)
.route("/{org_id}/db_monitoring/badges", get(db_monitoring::handler::get_dbm_badges))
// LLM Model Pricing
.route("/{org_id}/llm/models", get(model_pricing::list).post(model_pricing::create))
// NOTE: named routes MUST be registered before {model_id} or they match as a model ID
@@ -1471,7 +1439,6 @@ pub fn service_routes() -> Router {
.route("/{org_id}/prometheus/api/v1/metadata", get(promql::metadata))
.route("/{org_id}/prometheus/api/v1/series", get(promql::series_get).post(promql::series_post))
.route("/{org_id}/prometheus/api/v1/labels", get(promql::labels_get).post(promql::labels_post))
.route("/{org_id}/prometheus/api/v1/label/{label_name}/values", get(promql::label_values))
.route("/{org_id}/prometheus/api/v1/format_query", get(promql::format_query_get).post(promql::format_query_post))
.route("/{org_id}/prometheus/api/v1/parse_tree", post(promql::parse_tree))
@@ -1484,12 +1451,6 @@ pub fn service_routes() -> Router {
.route("/{org_id}/_search_history", post(search::search_history))
.route("/{org_id}/result_schema", post(search::result_schema))
// Multi-stream search
.route("/{org_id}/_search_multi", post(search::multi_streams::search_multi))
.route("/{org_id}/_search_multi_stream", post(search::multi_streams::search_multi_stream))
.route("/{org_id}/_search_partition_multi", post(search::multi_streams::_search_partition_multi))
.route("/{org_id}/{stream_names}/_around_multi", get(search::multi_streams::around_multi))
// HTTP/2 streaming
.route("/{org_id}/_search_stream", post(search::search_stream::search_http2_stream))
.route("/{org_id}/_values_stream", post(search::search_stream::values_http2_stream))
@@ -1643,8 +1604,7 @@ pub fn service_routes() -> Router {
// Anomaly Detection
.route("/{org_id}/anomaly_detection", get(anomaly_detection::list_configs).post(anomaly_detection::create_config))
.route("/{org_id}/anomaly_detection/{config_id}", get(anomaly_detection::get_config).put(anomaly_detection::update_config).delete(anomaly_detection::delete_config))
.route("/{org_id}/anomaly_detection/{config_id}/train", post(anomaly_detection::train_model).delete(anomaly_detection::cancel_training))
.route("/{org_id}/anomaly_detection/{config_id}/detect", post(anomaly_detection::detect_anomalies))
.route("/{org_id}/anomaly_detection/{config_id}/train", delete(anomaly_detection::cancel_training))
.route("/{org_id}/anomaly_detection/{config_id}/history", get(anomaly_detection::get_detection_history))
.route("/{org_id}/anomaly_detection/history", get(alerts::history::get_all_anomaly_history));
router = alert_calibration_routes(router);
@@ -1755,21 +1715,11 @@ pub fn service_routes() -> Router {
)
.route(
"/{org_id}/annotation_queues/{queue_id}/items",
post(annotation_queues::enqueue_annotation_queue_item)
.delete(annotation_queues::clear_annotation_queue_items),
)
.route(
"/{org_id}/annotation_queues/{queue_id}/items/{queue_item_id}",
get(annotation_queues::get_annotation_queue_item),
delete(annotation_queues::clear_annotation_queue_items),
)
.route(
"/{org_id}/annotation_queues/{queue_id}/items/{queue_item_id}/reviews",
get(annotation_queues::list_annotation_queue_item_reviews)
.post(annotation_queues::review_annotation_queue_item),
)
.route(
"/{org_id}/annotation_queues/{queue_id}/items/{queue_item_id}/push_to_dataset",
post(datasets::push_annotation_queue_item_to_dataset),
post(annotation_queues::review_annotation_queue_item),
)
.route(
"/{org_id}/annotation_queues/{queue_id}/items/archive",
@@ -1791,9 +1741,7 @@ pub fn service_routes() -> Router {
)
.route(
"/{org_id}/datasets/{dataset_id}/items",
get(datasets::list_dataset_items)
.post(datasets::push_dataset_item)
.put(datasets::upsert_dataset_items),
get(datasets::list_dataset_items).put(datasets::upsert_dataset_items),
)
.route(
"/{org_id}/datasets/{dataset_id}/rows",
@@ -1819,27 +1767,15 @@ pub fn service_routes() -> Router {
"/{org_id}/experiments",
get(experiments::list_experiments).post(experiments::create_experiment),
)
.route(
"/{org_id}/experiments/compare",
get(experiments::compare_experiments),
)
.route(
"/{org_id}/experiments/{experiment_id}",
get(experiments::get_experiment).delete(experiments::delete_experiment),
delete(experiments::delete_experiment),
)
.route(
"/{org_id}/experiments/{experiment_id}/baseline",
put(experiments::set_experiment_baseline)
.delete(experiments::clear_experiment_baseline),
)
.route(
"/{org_id}/experiments/{experiment_id}/rows",
get(experiments::list_experiment_result_rows),
)
.route(
"/{org_id}/experiments/{experiment_id}/rows/{row_id}",
get(experiments::get_experiment_row),
)
.route(
"/{org_id}/experiments/{experiment_id}/rows/{row_id}/trials/{trial_index}/retry",
post(experiments::retry_experiment_slot),
@@ -1872,9 +1808,6 @@ pub fn service_routes() -> Router {
// On-demand human annotation from Discovery
.route("/{org_id}/annotations", post(annotations::annotate_target))
// LLM score-based Discovery
.route("/{org_id}/discovery", get(discovery::list_discovery_items))
// LLM Providers (Online Eval Phase 2)
.route("/{org_id}/providers", get(providers::list_providers).post(providers::create_provider))
.route("/{org_id}/providers/test", post(providers::test_provider_config))
@@ -1892,7 +1825,6 @@ pub fn service_routes() -> Router {
.route("/{org_id}/tasks/{entity_id}/signing/end_grace", post(remote_tasks::end_remote_task_signing_grace))
.route("/{org_id}/tasks/{entity_id}/signing", get(remote_tasks::get_remote_task_signing_status).delete(remote_tasks::revoke_remote_task_signing_secret))
.route("/{org_id}/tasks/{entity_id}/versions", get(remote_tasks::list_remote_task_versions))
.route("/{org_id}/tasks/{entity_id}/stats", get(remote_tasks::get_remote_task_stats))
.route("/{org_id}/tasks/{entity_id}/draft", get(remote_tasks::get_remote_task_draft).delete(remote_tasks::discard_remote_task_draft))
.route("/{org_id}/tasks/{entity_id}/test_connection", post(remote_tasks::publish_remote_task))
.route("/{org_id}/tasks/{entity_id}/test_run", post(remote_tasks::test_run_remote_task))
@@ -1934,11 +1866,9 @@ pub fn service_routes() -> Router {
{
router = fleet_routes(router)
// Search jobs
.route("/{org_id}/search_jobs", get(search::search_job::list_status).post(search::search_job::submit_job))
.route("/{org_id}/search_jobs", get(search::search_job::list_status))
.route("/{org_id}/search_jobs/{job_id}", get(search::search_job::get_status).delete(search::search_job::delete_job))
.route("/{org_id}/search_jobs/{job_id}/result", get(search::search_job::get_job_result))
.route("/{org_id}/search_jobs/{job_id}/cancel", post(search::search_job::cancel_job))
.route("/{org_id}/search_jobs/{job_id}/retry", post(search::search_job::retry_job))
// Query manager
.route("/{org_id}/query_manager/status", get(search::query_manager::query_status))
@@ -1976,16 +1906,6 @@ pub fn service_routes() -> Router {
.route("/license", get(license::get_license_info).post(license::store_license))
.route("/license/refresh", post(license::refresh_license_limits))
// Topology
.route("/{org_id}/traces/service_graph/topology/current", get(traces::get_current_topology))
.route("/{org_id}/traces/service_graph/edge/history", get(traces::get_edge_history))
// Agent behavior signals (loop / failure / cost) — reads the _agent_signals stream
.route("/{org_id}/traces/agent_signals", get(traces::get_agent_signals))
.route("/{org_id}/traces/agent_signals/compare", post(traces::compare_agent_versions))
// Patterns
.route("/{org_id}/streams/{stream_name}/patterns/extract", post(patterns::extract_patterns))
// Service streams
.route("/{org_id}/service_streams", get(service_streams::list_services))
.route("/{org_id}/service_streams/config/identity", get(service_streams::get_identity_config).put(service_streams::save_identity_config))
@@ -2378,8 +2298,6 @@ pub fn downtime_routes() -> Router {
Router::new()
.route("/v2/{org_id}/downtimes", get(downtimes::list_downtimes).post(downtimes::create_downtime))
.route("/v2/{org_id}/downtimes/preview", post(downtimes::preview_downtime))
.route("/v2/{org_id}/downtimes/resources", post(downtimes::downtime_resources))
.route("/v2/{org_id}/downtimes/values", post(downtimes::downtime_values))
.route("/v2/{org_id}/downtimes/move", patch(downtimes::move_downtimes))
.route("/v2/{org_id}/downtimes/{downtime_id}", get(downtimes::get_downtime).put(downtimes::update_downtime).delete(downtimes::delete_downtime))
.route("/v2/{org_id}/downtimes/{downtime_id}/cancel", post(downtimes::cancel_downtime))
@@ -2405,6 +2323,101 @@ fn report_routes() -> Router {
.route("/v2/{org_id}/reports/{report_id}/trigger", put(dashboards::reports::trigger_report_v2))
}
/// Routes that read customer data through search; the licence gate answers 429 while search is blocked.
#[rustfmt::skip]
fn search_routes() -> Router {
#[cfg(feature = "enterprise")]
let llm_eval = get_o2_config().llm_eval_config.enabled;
#[cfg(not(feature = "enterprise"))]
let llm_eval = false;
search_routes_with(llm_eval)
}
#[rustfmt::skip]
#[cfg_attr(not(feature = "enterprise"), allow(unused_variables))]
fn search_routes_with(llm_eval: bool) -> Router {
let router = Router::new()
// Multi-stream search
.route("/{org_id}/_search_multi", post(search::multi_streams::search_multi))
.route("/{org_id}/_search_multi_stream", post(search::multi_streams::search_multi_stream))
.route("/{org_id}/_search_partition_multi", post(search::multi_streams::_search_partition_multi))
.route("/{org_id}/{stream_names}/_around_multi", get(search::multi_streams::around_multi))
.route("/{org_id}/{stream_name}/traces/{trace_id}/details", get(traces::details::get_trace_details))
.route("/{org_id}/prometheus/api/v1/label/{label_name}/values", get(promql::label_values))
.route("/v2/{org_id}/downtimes/resources", post(downtimes::downtime_resources))
.route("/v2/{org_id}/downtimes/values", post(downtimes::downtime_values))
// Database Monitoring — its own top-level module, not under /traces.
// The path segment must stay `db_monitoring` to match the OFGA resource
// these routes authorize against (`db_monitoring:{org}`, see o2_openfga
// ROUTE_PERMISSIONS).
//
// Every route below is registered in both builds; the build-type gate
// lives in the handlers, three of which (deadlocks/blocking/
// table_health) are dual-implemented and answer 403 on OSS. Registering
// them in the enterprise-gated block instead would 404 them, losing the
// distinction between "not licensed" and "no such endpoint". Runtime
// off-switch is ZO_DB_MONITORING_ENABLED.
.route("/{org_id}/db_monitoring/databases", get(db_monitoring::handler::get_dbm_databases))
.route("/{org_id}/db_monitoring/queries", get(db_monitoring::handler::get_dbm_queries))
.route("/{org_id}/db_monitoring/query/history", get(db_monitoring::handler::get_dbm_query_history))
.route("/{org_id}/db_monitoring/query/endpoints", get(db_monitoring::handler::get_dbm_query_endpoints))
.route("/{org_id}/db_monitoring/samples", get(db_monitoring::handler::get_dbm_samples))
// Server-vantage events (read the canonical o2_dbm_* columns)
.route("/{org_id}/db_monitoring/deadlocks", get(db_monitoring::handler::get_dbm_deadlocks))
.route("/{org_id}/db_monitoring/blocking", get(db_monitoring::handler::get_dbm_blocking))
.route("/{org_id}/db_monitoring/activity", get(db_monitoring::handler::get_dbm_activity))
// `query/insights` returns the query-detail page's Logs-side pair in one
// round trip; `query/plans` and `query/server_metrics` are superseded by
// it (same sections, same envelopes) and stay registered for
// compatibility.
.route("/{org_id}/db_monitoring/query/insights", get(db_monitoring::handler::get_dbm_query_insights))
.route("/{org_id}/db_monitoring/query/plans", get(db_monitoring::handler::get_dbm_query_plans))
.route("/{org_id}/db_monitoring/query/server_metrics", get(db_monitoring::handler::get_dbm_query_server_metrics))
.route("/{org_id}/db_monitoring/server_queries", get(db_monitoring::handler::get_dbm_server_queries))
.route("/{org_id}/db_monitoring/server_samples", get(db_monitoring::handler::get_dbm_server_samples))
.route("/{org_id}/db_monitoring/table_health", get(db_monitoring::handler::get_dbm_table_health))
.route("/{org_id}/db_monitoring/instances", get(db_monitoring::handler::get_dbm_instances))
.route(
"/{org_id}/db_monitoring/instance_metrics",
get(db_monitoring::handler::get_dbm_instance_metrics),
)
.route("/{org_id}/db_monitoring/badges", get(db_monitoring::handler::get_dbm_badges));
#[cfg(feature = "enterprise")]
let router = {
let router = router
.route("/{org_id}/anomaly_detection/{config_id}/train", post(anomaly_detection::train_model))
.route("/{org_id}/anomaly_detection/{config_id}/detect", post(anomaly_detection::detect_anomalies))
.route("/{org_id}/search_jobs", post(search::search_job::submit_job))
.route("/{org_id}/search_jobs/{job_id}/result", get(search::search_job::get_job_result))
.route("/{org_id}/search_jobs/{job_id}/retry", post(search::search_job::retry_job))
.route("/{org_id}/traces/service_graph/topology/current", get(traces::get_current_topology))
.route("/{org_id}/traces/service_graph/edge/history", get(traces::get_edge_history))
// Agent behavior signals (loop / failure / cost) — reads the _agent_signals stream
.route("/{org_id}/traces/agent_signals", get(traces::get_agent_signals))
.route("/{org_id}/traces/agent_signals/compare", post(traces::compare_agent_versions))
.route("/{org_id}/streams/{stream_name}/patterns/extract", post(patterns::extract_patterns));
if llm_eval {
router
.route("/{org_id}/annotation_queues/{queue_id}/items", post(annotation_queues::enqueue_annotation_queue_item))
.route("/{org_id}/annotation_queues/{queue_id}/items/{queue_item_id}", get(annotation_queues::get_annotation_queue_item))
.route("/{org_id}/annotation_queues/{queue_id}/items/{queue_item_id}/reviews", get(annotation_queues::list_annotation_queue_item_reviews))
.route("/{org_id}/annotation_queues/{queue_id}/items/{queue_item_id}/push_to_dataset", post(datasets::push_annotation_queue_item_to_dataset))
.route("/{org_id}/datasets/{dataset_id}/items", post(datasets::push_dataset_item))
.route("/{org_id}/experiments/compare", get(experiments::compare_experiments))
.route("/{org_id}/experiments/{experiment_id}", get(experiments::get_experiment))
.route("/{org_id}/experiments/{experiment_id}/rows", get(experiments::list_experiment_result_rows))
.route("/{org_id}/experiments/{experiment_id}/rows/{row_id}", get(experiments::get_experiment_row))
.route("/{org_id}/discovery", get(discovery::list_discovery_items))
.route("/{org_id}/tasks/{entity_id}/stats", get(remote_tasks::get_remote_task_stats))
} else {
router
}
};
// A route layer, not a layer: it must not reach the ungated methods merged onto the same paths.
router.route_layer(middleware::from_fn(search_license_middleware))
}
/// On-call management routes; the caller mounts them only when on-call is enabled.
#[cfg(feature = "enterprise")]
fn oncall_routes(router: Router) -> Router {
@@ -1683,6 +1683,9 @@ mod tests {
#[cfg(feature = "enterprise")]
const SERVICE_ROUTES_SIGNATURE: &str = "pub fn service_routes(";
#[cfg(feature = "enterprise")]
const SEARCH_ROUTES_SIGNATURE: &str = "fn search_routes_with(";
#[cfg(feature = "enterprise")]
const HTTP_METHODS: &[&str] = &[
"get", "post", "put", "delete", "patch", "head", "options", "trace",
@@ -1909,13 +1912,18 @@ mod tests {
// Ends at the column-0 close brace, so an item after the body is never scraped into it.
#[cfg(feature = "enterprise")]
fn service_routes_body() -> &'static str {
router_fn_body(SERVICE_ROUTES_SIGNATURE)
}
#[cfg(feature = "enterprise")]
fn router_fn_body(signature: &str) -> &'static str {
let start = ROUTER_SOURCE
.find(SERVICE_ROUTES_SIGNATURE)
.expect("the router must define service_routes()");
let after_signature = &ROUTER_SOURCE[start + SERVICE_ROUTES_SIGNATURE.len()..];
.find(signature)
.unwrap_or_else(|| panic!("the router must define {signature}"));
let after_signature = &ROUTER_SOURCE[start + signature.len()..];
let end = after_signature
.find("\n}\n")
.expect("service_routes() must end at a column-0 close brace");
.unwrap_or_else(|| panic!("{signature} must end at a column-0 close brace"));
&after_signature[..end]
}
@@ -2014,7 +2022,13 @@ mod tests {
#[cfg(feature = "enterprise")]
fn registered_gated_routes() -> BTreeSet<(String, String)> {
let live = normalize(&strip_comments(service_routes_body()));
// The licence-gated search routes are a separate group that service_routes() merges in.
let routes = [
service_routes_body(),
router_fn_body(SEARCH_ROUTES_SIGNATURE),
]
.concat();
let live = normalize(&strip_comments(&routes));
let mut routes = BTreeSet::new();
for call in live.split(".route(").skip(1) {
let Some(arguments) = balanced_arguments(call) else {
@@ -0,0 +1,618 @@
// Copyright 2026 OpenObserve Inc.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use std::collections::BTreeSet;
use axum::body::Body;
use tower::ServiceExt;
use super::*;
/// Every route `search_routes_with` registers, as (method, template, handler).
#[rustfmt::skip]
const GATED: [(&str, &str, &str); 46] = [
("POST", "/{org_id}/_search_multi", "search::multi_streams::search_multi"),
("POST", "/{org_id}/_search_multi_stream", "search::multi_streams::search_multi_stream"),
("POST", "/{org_id}/_search_partition_multi", "search::multi_streams::_search_partition_multi"),
("GET", "/{org_id}/{stream_names}/_around_multi", "search::multi_streams::around_multi"),
("GET", "/{org_id}/{stream_name}/traces/{trace_id}/details", "traces::details::get_trace_details"),
("GET", "/{org_id}/prometheus/api/v1/label/{label_name}/values", "promql::label_values"),
("POST", "/v2/{org_id}/downtimes/resources", "downtimes::downtime_resources"),
("POST", "/v2/{org_id}/downtimes/values", "downtimes::downtime_values"),
("GET", "/{org_id}/db_monitoring/databases", "db_monitoring::handler::get_dbm_databases"),
("GET", "/{org_id}/db_monitoring/queries", "db_monitoring::handler::get_dbm_queries"),
("GET", "/{org_id}/db_monitoring/query/history", "db_monitoring::handler::get_dbm_query_history"),
("GET", "/{org_id}/db_monitoring/query/endpoints", "db_monitoring::handler::get_dbm_query_endpoints"),
("GET", "/{org_id}/db_monitoring/samples", "db_monitoring::handler::get_dbm_samples"),
("GET", "/{org_id}/db_monitoring/deadlocks", "db_monitoring::handler::get_dbm_deadlocks"),
("GET", "/{org_id}/db_monitoring/blocking", "db_monitoring::handler::get_dbm_blocking"),
("GET", "/{org_id}/db_monitoring/activity", "db_monitoring::handler::get_dbm_activity"),
("GET", "/{org_id}/db_monitoring/query/insights", "db_monitoring::handler::get_dbm_query_insights"),
("GET", "/{org_id}/db_monitoring/query/plans", "db_monitoring::handler::get_dbm_query_plans"),
("GET", "/{org_id}/db_monitoring/query/server_metrics", "db_monitoring::handler::get_dbm_query_server_metrics"),
("GET", "/{org_id}/db_monitoring/server_queries", "db_monitoring::handler::get_dbm_server_queries"),
("GET", "/{org_id}/db_monitoring/server_samples", "db_monitoring::handler::get_dbm_server_samples"),
("GET", "/{org_id}/db_monitoring/table_health", "db_monitoring::handler::get_dbm_table_health"),
("GET", "/{org_id}/db_monitoring/instances", "db_monitoring::handler::get_dbm_instances"),
("GET", "/{org_id}/db_monitoring/instance_metrics", "db_monitoring::handler::get_dbm_instance_metrics"),
("GET", "/{org_id}/db_monitoring/badges", "db_monitoring::handler::get_dbm_badges"),
("POST", "/{org_id}/anomaly_detection/{config_id}/train", "anomaly_detection::train_model"),
("POST", "/{org_id}/anomaly_detection/{config_id}/detect", "anomaly_detection::detect_anomalies"),
("POST", "/{org_id}/search_jobs", "search::search_job::submit_job"),
("GET", "/{org_id}/search_jobs/{job_id}/result", "search::search_job::get_job_result"),
("POST", "/{org_id}/search_jobs/{job_id}/retry", "search::search_job::retry_job"),
("GET", "/{org_id}/traces/service_graph/topology/current", "traces::get_current_topology"),
("GET", "/{org_id}/traces/service_graph/edge/history", "traces::get_edge_history"),
("GET", "/{org_id}/traces/agent_signals", "traces::get_agent_signals"),
("POST", "/{org_id}/traces/agent_signals/compare", "traces::compare_agent_versions"),
("POST", "/{org_id}/streams/{stream_name}/patterns/extract", "patterns::extract_patterns"),
("POST", "/{org_id}/annotation_queues/{queue_id}/items", "annotation_queues::enqueue_annotation_queue_item"),
("GET", "/{org_id}/annotation_queues/{queue_id}/items/{queue_item_id}", "annotation_queues::get_annotation_queue_item"),
("GET", "/{org_id}/annotation_queues/{queue_id}/items/{queue_item_id}/reviews", "annotation_queues::list_annotation_queue_item_reviews"),
("POST", "/{org_id}/annotation_queues/{queue_id}/items/{queue_item_id}/push_to_dataset", "datasets::push_annotation_queue_item_to_dataset"),
("POST", "/{org_id}/datasets/{dataset_id}/items", "datasets::push_dataset_item"),
("GET", "/{org_id}/experiments/compare", "experiments::compare_experiments"),
("GET", "/{org_id}/experiments/{experiment_id}", "experiments::get_experiment"),
("GET", "/{org_id}/experiments/{experiment_id}/rows", "experiments::list_experiment_result_rows"),
("GET", "/{org_id}/experiments/{experiment_id}/rows/{row_id}", "experiments::get_experiment_row"),
("GET", "/{org_id}/discovery", "discovery::list_discovery_items"),
("GET", "/{org_id}/tasks/{entity_id}/stats", "remote_tasks::get_remote_task_stats"),
];
/// Modules whose every registered handler reads data through search.
const FULLY_GATED_MODULES: [&str; 5] = [
"search::multi_streams::",
"db_monitoring::handler::",
"patterns::",
"discovery::",
"traces::details::",
];
/// Modules that hold gated and ungated handlers side by side.
const MIXED_MODULES: [&str; 9] = [
"search::search_job",
"anomaly_detection",
"experiments",
"annotation_queues",
"datasets",
"remote_tasks",
"downtimes",
"promql",
"traces",
];
/// Each ungated handler of a mixed module, with why it stays open.
#[rustfmt::skip]
const UNGATED_SIBLINGS: &[(&str, &str)] = &[
("annotation_queues::archive_annotation_queue_items", "queue bookkeeping, no search"),
("annotation_queues::clear_annotation_queue_items", "queue bookkeeping, no search"),
("annotation_queues::create_annotation_queue", "queue bookkeeping, no search"),
("annotation_queues::delete_annotation_queue", "queue bookkeeping, no search"),
("annotation_queues::get_annotation_queue", "queue bookkeeping, no search"),
("annotation_queues::list_annotation_queue_items", "stored queue items, no search"),
("annotation_queues::list_annotation_queues", "queue bookkeeping, no search"),
("annotation_queues::review_annotation_queue_item", "stores a human review, no search"),
("annotation_queues::update_annotation_queue", "queue bookkeeping, no search"),
("anomaly_detection::cancel_training", "stops work, no search"),
("anomaly_detection::create_config", "create-time training waits in the scheduler while blocked"),
("anomaly_detection::delete_config", "config bookkeeping, no search"),
("anomaly_detection::get_config", "config bookkeeping, no search"),
("anomaly_detection::get_detection_history", "operational history of the org's own detections"),
("anomaly_detection::list_configs", "config bookkeeping, no search"),
("anomaly_detection::update_config", "config bookkeeping, no search"),
("datasets::create_dataset", "dataset bookkeeping, no search"),
("datasets::delete_dataset", "dataset bookkeeping, no search"),
("datasets::delete_dataset_item", "dataset bookkeeping, no search"),
("datasets::get_dataset", "dataset bookkeeping, no search"),
("datasets::get_dataset_item_versions", "stored dataset rows, no search"),
("datasets::get_dataset_snapshot_rows", "stored dataset rows, no search"),
("datasets::import_dataset_items", "rows come from the uploaded CSV, not telemetry"),
("datasets::list_dataset_items", "stored dataset rows, no search"),
("datasets::list_datasets", "dataset bookkeeping, no search"),
("datasets::update_dataset", "dataset bookkeeping, no search"),
("datasets::update_dataset_item", "rows come from the caller, not telemetry"),
("datasets::upsert_dataset_items", "rows come from the caller, not telemetry"),
("downtimes::cancel_downtime", "downtime bookkeeping, no search"),
("downtimes::create_downtime", "downtime bookkeeping, no search"),
("downtimes::delete_downtime", "downtime bookkeeping, no search"),
("downtimes::extend_downtime", "downtime bookkeeping, no search"),
("downtimes::get_downtime", "downtime bookkeeping, no search"),
("downtimes::list_downtimes", "downtime bookkeeping, no search"),
("downtimes::move_downtimes", "downtime bookkeeping, no search"),
("downtimes::preview_downtime", "inventory match only"),
("downtimes::update_downtime", "downtime bookkeeping, no search"),
("experiments::cancel_experiment", "stops work, no search"),
("experiments::clear_experiment_baseline", "experiment bookkeeping, no search"),
("experiments::clone_experiment", "experiment bookkeeping; runs score as background eval work"),
("experiments::create_experiment", "runs score as background eval work"),
("experiments::delete_experiment", "experiment bookkeeping, no search"),
("experiments::finalize_experiment", "experiment bookkeeping, no search"),
("experiments::list_experiment_slots", "slot bookkeeping, no search"),
("experiments::list_experiments", "summary skipped while blocked (S4)"),
("experiments::preview_experiment", "previews the dataset, not telemetry"),
("experiments::retry_experiment", "runs score as background eval work"),
("experiments::retry_experiment_slot", "runs score as background eval work"),
("experiments::set_experiment_baseline", "experiment bookkeeping, no search"),
("experiments::submit_experiment_records", "records come from the caller"),
("promql::format_query_get", "formats the query text, no data"),
("promql::format_query_post", "formats the query text, no data"),
("promql::labels_get", "schema only"),
("promql::labels_post", "schema only"),
("promql::metadata", "schema only"),
("promql::parse_tree", "parses the query text, no data"),
("promql::query_exemplars_get", "checks the licence in the handler (query_range)"),
("promql::query_exemplars_post", "checks the licence in the handler (query_range)"),
("promql::query_get", "checks the licence in the handler"),
("promql::query_post", "checks the licence in the handler"),
("promql::query_range_get", "checks the licence in the handler"),
("promql::query_range_post", "checks the licence in the handler"),
("promql::remote_write", "ingestion"),
("promql::series_get", "checks the licence in the handler"),
("promql::series_post", "checks the licence in the handler"),
("remote_tasks::activate_remote_task_signing_candidate", "task bookkeeping, no search"),
("remote_tasks::create_remote_task", "task bookkeeping, no search"),
("remote_tasks::delete_remote_task", "task bookkeeping, no search"),
("remote_tasks::discard_remote_task_draft", "task bookkeeping, no search"),
("remote_tasks::end_remote_task_signing_grace", "task bookkeeping, no search"),
("remote_tasks::get_remote_task", "task bookkeeping, no search"),
("remote_tasks::get_remote_task_draft", "task bookkeeping, no search"),
("remote_tasks::get_remote_task_signing_status", "task bookkeeping, no search"),
("remote_tasks::list_remote_task_versions", "task bookkeeping, no search"),
("remote_tasks::list_remote_tasks", "task bookkeeping, no search"),
("remote_tasks::publish_remote_task", "calls the remote endpoint, no search"),
("remote_tasks::replace_remote_task_auth_secret", "task bookkeeping, no search"),
("remote_tasks::replace_remote_task_header_secret", "task bookkeeping, no search"),
("remote_tasks::revoke_remote_task_auth_secret", "task bookkeeping, no search"),
("remote_tasks::revoke_remote_task_header_secret", "task bookkeeping, no search"),
("remote_tasks::revoke_remote_task_signing_secret", "task bookkeeping, no search"),
("remote_tasks::rotate_remote_task_signing_secret", "task bookkeeping, no search"),
("remote_tasks::save_remote_task_draft", "task bookkeeping, no search"),
("remote_tasks::test_remote_task", "calls the remote endpoint, no search"),
("remote_tasks::test_remote_task_signing_candidate", "calls the remote endpoint, no search"),
("remote_tasks::test_run_remote_task", "calls the remote endpoint on caller input"),
("search::search_job::cancel_job", "job bookkeeping, no search"),
("search::search_job::delete_job", "job bookkeeping, no search"),
("search::search_job::get_status", "job bookkeeping, no search"),
("search::search_job::list_status", "job bookkeeping, no search"),
("traces::get_latest_traces", "checks the licence in the handler"),
("traces::get_latest_traces_stream", "checks the licence in the handler"),
("traces::traces_write", "ingestion"),
];
/// Pages an over-limit customer needs to understand and fix their state; none may be gated.
const OPERATIONAL_READS: [&str; 22] = [
"/v2/{org_id}/alerts/history",
"/v2/{org_id}/reports/history",
"/{org_id}/pipelines/history",
"/{org_id}/workflows/{id}/history",
"/v2/{org_id}/alert_calibration/digests",
"/v2/{org_id}/alert_calibration/digests/alerts",
"/v2/{org_id}/alert_calibration/digests/records",
"/v2/{org_id}/alert_calibration/digests/rules",
"/v2/{org_id}/alert_calibration/digests/run",
"/{org_id}/ai/chats",
"/{org_id}/ai/chats/{session_id}",
"/{org_id}/synthetics",
"/{org_id}/synthetics/{id}/runs",
"/{org_id}/_search_history",
"/{org_id}/search/profile",
"/license",
"/license/refresh",
"/{org_id}/config",
"/{org_id}/anomaly_detection/{config_id}/history",
"/{org_id}/anomaly_detection/history",
"/v2/{org_id}/alerts/incidents/{incident_id}",
"/{org_id}/search_jobs/{job_id}",
];
const SEARCH_ENTRY: &str = r"(?:search_service|SearchService|crate::search|core_promql::search)::(?:cache::)?(?:search|search_multi|search_partition|search_partition_multi)\(|\bgrpc_search(?:_partition)?\(|process_search_stream_request(?:_multi)?\(|eval_jobs::search::execute|execute_training_query\(";
/// Every production file that calls a search entry point, and how its search is licence-gated.
#[rustfmt::skip]
const SEARCH_CALLERS: [(&str, Caller); 39] = [
("api/management/src/request/alert_calibration/digests.rs", Caller::Operational),
("api/management/src/request/alerts/history.rs", Caller::Exempt("the org's own alert and anomaly run history; its routes stay open")),
("api/management/src/request/dashboards/reports_history.rs", Caller::Exempt("the org's own report run history; its route stays open")),
("api/management/src/request/db_monitoring/service/common.rs", Caller::Routed("db_monitoring::handler::")),
("api/management/src/request/db_monitoring/service/instance_metrics.rs", Caller::Routed("db_monitoring::handler::")),
("api/management/src/request/synthetics/last_check.rs", Caller::Exempt("last check on the synthetics list; its route stays open")),
("api/management/src/request/workflows/mod.rs", Caller::Exempt("the org's own workflow run history; its route stays open")),
("api/pipelines/src/request/pipelines/history.rs", Caller::Exempt("the org's own pipeline run history; its route stays open")),
("api/search/src/profiles/mod.rs", Caller::InHandler),
("api/search/src/promql/mod.rs", Caller::InHandler),
("api/search/src/rum/lookup/query.rs", Caller::Helper("called by rum lookup, which checks the licence in rum/lookup/mod.rs")),
("api/search/src/search/around.rs", Caller::Helper("around v1/v2 check in search/mod.rs; around_multi is in the gated group")),
("api/search/src/search/mod.rs", Caller::InHandler),
("api/search/src/search/multi_streams.rs", Caller::Routed("search::multi_streams::")),
("api/search/src/search/patterns.rs", Caller::Routed("patterns::")),
("api/search/src/search/search_inspector.rs", Caller::Exempt("search/profile stays open (D13)")),
("api/search/src/search/search_stream.rs", Caller::InHandler),
("api/search/src/traces/dag.rs", Caller::InHandler),
("api/search/src/traces/details.rs", Caller::Routed("traces::details::")),
("api/search/src/traces/list/query.rs", Caller::Helper("latest traces; traces/mod.rs checks the licence")),
("api/search/src/traces/list/stream.rs", Caller::Helper("latest traces; traces/mod.rs checks the licence")),
("api/search/src/traces/session.rs", Caller::InHandler),
("api/search/src/traces/time_index/mod.rs", Caller::InHandler),
("api/search/src/traces/user.rs", Caller::InHandler),
("core/src/ai_chat.rs", Caller::Operational),
("core/src/alerts/alert_calibration/mod.rs", Caller::Background("calibration runs")),
("core/src/alerts/mod.rs", Caller::Background("alert evaluation")),
("core/src/alerts/notifications/chart/mod.rs", Caller::Background("notification charts")),
("core/src/anomaly_detection/partitioned_search.rs", Caller::Background("anomaly detection runs")),
("core/src/db_monitoring/rollup.rs", Caller::Background("DBM rollup")),
("core/src/downtimes/values.rs", Caller::Routed("downtimes::")),
("core/src/metrics/prom.rs", Caller::Routed("promql::label_values")),
("core/src/self_reporting/search.rs", Caller::Exempt("licence usage from the meta org")),
("core/src/slo/job.rs", Caller::Background("SLO job")),
("core/src/slo/reconcile.rs", Caller::Background("SLO reconcile")),
("core/src/traces/agent_signals/api.rs", Caller::Routed("traces::get_agent_signals")),
("core/src/traces/service_graph/mod.rs", Caller::Routed("traces::get_current_topology")),
("jobs/src/job/mod.rs", Caller::Background("eval-job scoring")),
("jobs/src/job/red_insights.rs", Caller::Background("red_insights")),
];
/// How a search call site is licence-gated.
enum Caller {
/// The file calls `check_search_allowed` itself.
InHandler,
/// The file serves handlers under this prefix, which sit in the gated group.
Routed(&'static str),
/// The file reads only an operational stream.
Operational,
Background(&'static str),
Helper(&'static str),
Exempt(&'static str),
}
type Registration = (String, String, String);
fn production_source() -> &'static str {
include_str!("mod.rs")
.split_once("\n#[cfg(test)]\nmod tests {")
.unwrap()
.0
}
fn fn_body<'a>(source: &'a str, signature: &str) -> &'a str {
let start = source.find(signature).unwrap();
&source[start..start + source[start..].find("\n}\n").unwrap()]
}
/// The `/api` tree the router serves, minus the gated group.
fn api_span_outside_the_group() -> String {
let source = production_source();
let section = |from: &str, to: &str| {
let start = source.find(from).unwrap();
&source[start..start + source[start..].find(to).unwrap()]
};
let api = section("fn ai_chat_routes(", "pub fn other_service_routes(");
let group = fn_body(source, "fn search_routes_with(");
format!(
"{}{}",
api.replace(group, ""),
section("fn es_compat_routes(", "\n}\n")
)
}
/// Every (method, template, handler) the `.route(` calls in `text` register.
fn registrations(text: &str) -> BTreeSet<Registration> {
let handler = regex::Regex::new(
r"(?:^|[\s(.,])(get|post|put|delete|patch|head)\(([A-Za-z_][A-Za-z0-9_:]*)\)",
)
.unwrap();
let mut found = BTreeSet::new();
for (at, _) in text.match_indices(".route(") {
let args = &text[at + ".route(".len()..];
let mut depth = 1;
let end = args
.char_indices()
.find(|(_, c)| {
depth += match c {
'(' => 1,
')' => -1,
_ => 0,
};
depth == 0
})
.unwrap()
.0;
let args = &args[..end];
let template = args.split('"').nth(1).unwrap().to_string();
for c in handler.captures_iter(args) {
found.insert((c[1].to_uppercase(), template.clone(), c[2].to_string()));
}
}
found
}
fn gated() -> BTreeSet<Registration> {
GATED
.iter()
.map(|(m, t, h)| (m.to_string(), t.to_string(), h.to_string()))
.collect()
}
fn module_of(handler: &str) -> &str {
handler.rsplit_once("::").map_or("", |(module, _)| module)
}
fn search_call_files() -> BTreeSet<String> {
let src = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
let mut roots: Vec<_> = std::fs::read_dir(src.join("api"))
.unwrap()
.map(|e| e.unwrap().path().join("src"))
.collect();
roots.extend(["core/src", "mcp/src", "synthetics/src", "jobs/src"].map(|d| src.join(d)));
let entry = regex::Regex::new(SEARCH_ENTRY).unwrap();
let mut hits = BTreeSet::new();
let mut pending = roots;
while let Some(path) = pending.pop() {
if path.is_dir() {
pending.extend(std::fs::read_dir(&path).unwrap().map(|e| e.unwrap().path()));
continue;
}
let rel = path
.strip_prefix(&src)
.unwrap()
.to_string_lossy()
.replace('\\', "/");
// This file quotes the entry pattern, so scanning it would find itself.
if !rel.ends_with(".rs") || rel.ends_with("router/search_gate_tests.rs") {
continue;
}
let text = std::fs::read_to_string(&path).unwrap();
let production = text.split("\n#[cfg(test)]\nmod tests").next().unwrap();
if entry.is_match(production) {
hits.insert(rel);
}
}
hits
}
fn caller_source(file: &str) -> String {
let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../..")
.join(file);
let text = std::fs::read_to_string(path).unwrap();
text.split("\n#[cfg(test)]\nmod tests")
.next()
.unwrap()
.to_string()
}
#[cfg(feature = "enterprise")]
fn filled(template: &str) -> String {
template
.split('/')
.map(|segment| match segment {
"{org_id}" => "acme".to_string(),
"{stream_name}" => "s1".to_string(),
"{job_id}" => "j1".to_string(),
"{stream_names}" => config::utils::base64::encode_url("s1"),
s if s.starts_with('{') => "p1".to_string(),
s => s.to_string(),
})
.collect::<Vec<_>>()
.join("/")
}
#[test]
fn search_routes_hold_exactly_the_gated_routes() {
let group = registrations(fn_body(production_source(), "fn search_routes_with("));
assert_eq!(group, gated());
}
#[test]
fn no_gated_handler_is_registered_outside_search_routes() {
let outside = registrations(&api_span_outside_the_group());
let gated_handlers: BTreeSet<String> = GATED.iter().map(|(_, _, h)| h.to_string()).collect();
let siblings: BTreeSet<&str> = UNGATED_SIBLINGS.iter().map(|(h, _)| *h).collect();
let mut seen_siblings = BTreeSet::new();
for (method, template, handler) in &outside {
let route = format!("{method} {template} {handler}");
assert!(
!gated_handlers.contains(handler),
"gated handler outside the group: {route}"
);
assert!(
!FULLY_GATED_MODULES.iter().any(|m| handler.starts_with(m)),
"a fully gated module registers outside the group: {route}"
);
if MIXED_MODULES.contains(&module_of(handler)) {
assert!(
siblings.contains(handler.as_str()),
"unclassified handler: {route}"
);
seen_siblings.insert(handler.as_str());
}
}
assert_eq!(
seen_siblings, siblings,
"UNGATED_SIBLINGS lists a handler no route registers"
);
assert!(
UNGATED_SIBLINGS
.iter()
.all(|(_, reason)| !reason.is_empty())
);
}
#[test]
fn operational_reads_stay_outside_the_gate() {
let group: BTreeSet<String> =
registrations(fn_body(production_source(), "fn search_routes_with("))
.into_iter()
.map(|(_, template, _)| template)
.collect();
let outside: BTreeSet<String> = registrations(&api_span_outside_the_group())
.into_iter()
.map(|(_, template, _)| template)
.collect();
for template in OPERATIONAL_READS {
assert!(
outside.contains(template),
"{template} is no longer registered"
);
assert!(
!group.contains(template),
"{template} must stay outside the gate"
);
}
}
#[test]
fn the_gate_is_a_route_layer_on_the_group_only() {
let source = production_source();
assert_eq!(
source.matches("from_fn(search_license_middleware)").count(),
1
);
assert!(
fn_body(source, "fn search_routes_with(")
.contains(".route_layer(middleware::from_fn(search_license_middleware))")
);
assert!(!source.contains(".layer(middleware::from_fn(search_license_middleware"));
}
#[tokio::test]
async fn route_layer_does_not_reach_methods_merged_on_the_same_path() {
const PATH: &str = "/v2/{org_id}/downtimes/values";
let _built = Router::new()
.merge(search_routes_with(false))
.route(PATH, get(|| async { "stub" }));
let closed = middleware::from_fn(|_: Request, _: Next| async {
common::meta::http::HttpResponse::too_many_requests("blocked")
});
let app = Router::new()
.merge(
Router::new()
.route(PATH, post(|| async { "gated" }))
.route_layer(closed),
)
.route(PATH, get(|| async { "open" }));
let send = |method: Method| {
app.clone().oneshot(
axum::http::Request::builder()
.method(method)
.uri("/v2/acme/downtimes/values")
.body(Body::empty())
.unwrap(),
)
};
assert_eq!(send(Method::GET).await.unwrap().status(), StatusCode::OK);
assert_eq!(
send(Method::POST).await.unwrap().status(),
StatusCode::TOO_MANY_REQUESTS
);
}
#[test]
fn every_search_call_site_is_classified() {
let listed: BTreeSet<String> = SEARCH_CALLERS.iter().map(|(f, _)| f.to_string()).collect();
assert_eq!(
search_call_files(),
listed,
"classify every search call site in SEARCH_CALLERS"
);
let operational = ["DIGEST_STREAM", "AI_CHAT_EVENTS_STREAM"]
.into_iter()
.chain(search_service::OPERATIONAL_STREAMS);
let operational: Vec<&str> = operational.collect();
for (file, class) in SEARCH_CALLERS {
match class {
Caller::InHandler => {
assert!(
caller_source(file).contains("check_search_allowed("),
"{file}"
)
}
Caller::Routed(prefix) => {
assert!(
GATED.iter().any(|(_, _, h)| h.starts_with(prefix)),
"{file}"
)
}
Caller::Operational => {
let text = caller_source(file);
assert!(operational.iter().any(|name| text.contains(name)), "{file}")
}
Caller::Background(reason) | Caller::Helper(reason) | Caller::Exempt(reason) => {
assert!(!reason.is_empty(), "{file}")
}
}
}
}
#[cfg(feature = "enterprise")]
#[tokio::test]
async fn closed_gate_refuses_every_gated_route() {
use o2_enterprise::enterprise::license::{TEST_STATICS_LOCK, close_search_gate_for_tests};
let _gate = close_search_gate_for_tests(TEST_STATICS_LOCK.lock().await);
let app = search_routes_with(true);
for (method, template, handler) in GATED {
let request = axum::http::Request::builder()
.method(method)
.uri(filled(template))
.body(Body::empty())
.unwrap();
let response = app.clone().oneshot(request).await.unwrap();
assert_eq!(
response.status(),
StatusCode::TOO_MANY_REQUESTS,
"{method} {template} {handler}"
);
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(json["code"], 429, "{template}");
let message = json["message"].as_str().unwrap();
assert!(
message.starts_with(
"Error# Search is temporarily disabled due to exceeding allotted ingestion limit."
) && message.ends_with("Please contact your administrator."),
"{template}: {message}"
);
}
}
#[cfg(feature = "enterprise")]
#[tokio::test]
async fn meta_org_passes_the_closed_gate() {
use o2_enterprise::enterprise::license::{TEST_STATICS_LOCK, close_search_gate_for_tests};
let _gate = close_search_gate_for_tests(TEST_STATICS_LOCK.lock().await);
let request = axum::http::Request::post(format!("/{}/_search_multi", config::META_ORG_ID))
.body(Body::empty())
.unwrap();
let response = search_routes_with(true).oneshot(request).await.unwrap();
assert_ne!(response.status(), StatusCode::TOO_MANY_REQUESTS);
}
#[cfg(feature = "enterprise")]
#[tokio::test]
async fn open_gate_lets_a_gated_route_through() {
let _held = o2_enterprise::enterprise::license::TEST_STATICS_LOCK
.lock()
.await;
let app = Router::new()
.route("/{org_id}/{stream_name}/probe", get(|| async { "ok" }))
.route_layer(middleware::from_fn(search_license_middleware));
let request = axum::http::Request::get("/acme/logs/probe")
.body(Body::empty())
.unwrap();
assert_eq!(app.oneshot(request).await.unwrap().status(), StatusCode::OK);
}