Files
cairnobs/search/src/tenants.rs
T
jcoffey-dev 088677643f Close search's active-tenant write-routing gap with a polled allowlist
search/src/consumer.rs's write-routing (built last pass) had no active-
tenant check at all: IndexRegistry.resolve() would open-or-create an
index directory for any syntactically-valid tenant_id, active or not --
unlike ClickHouse's chwriter.Registry (an active-tenants-only snapshot
built at enterprise-ingest startup) or the read side (gated by
searchclient.TenantChecker, a direct rbacstore query). search is AGPL
core with no Postgres access and no enterprise/ import allowed, so it
needed a network boundary instead -- the same shape ingest's
TenantResolver already uses against enterprise-auth, just Rust calling
Go instead of Go calling Go.

New GET /internal/active-tenants endpoint on enterprise-auth
(rbacstore.ListActiveTenantIDs + authhandler.handleActiveTenants),
gated on a RoleService Bearer credential -- server-to-server auth, the
same shape alerting presents to api, minted via the already-generic
enterprise-auth -mint-service-token search. search/src/tenants.rs's
ActiveTenantTracker polls it every 60s, blocking startup on the first
fetch succeeding (fail-closed cold start -- a control-plane outage at
boot must not silently accept every tenant_id) and keeping the last-
known-good set on any later refresh failure (a transient blip shouldn't
stop every tenant's indexing, only prevent the allowlist from growing/
shrinking until connectivity resumes). consumer.rs refuses any tagged
record whose tenant isn't in the polled set, before ever calling
resolve() -- IndexRegistry itself stays policy-free, matching the same
mechanism/policy split clickhousewriter.Writer vs. chwriter.Registry
already draws on the ClickHouse side.

Off unless ENTERPRISE_AUTH_URL/ENTERPRISE_AUTH_SERVICE_TOKEN are both
set (search/src/config.rs rejects exactly one being set) -- every
existing deployment is unaffected.

Verified with real HTTP round trips in this environment: tenants.rs's
tests exercise real reqwest requests (actual Authorization: Bearer
header, actual JSON parsing) against a hand-rolled dependency-free TCP
test server, including both fail-closed paths (rejected first fetch,
unreachable server). authhandler's new tests cover the credential-kind
distinction this endpoint exists to enforce -- a real human session,
even for a genuine Owner, must not satisfy a check meant for a service
identity.

One asymmetry remains, disclosed rather than fixed: chwriter.Registry's
snapshot still never refreshes (stale until enterprise-ingest restarts),
while ActiveTenantTracker's 60s poll gives Tantivy a materially tighter
staleness window. Neither is a live per-write check -- that would mean
a database/HTTP round trip per record, a throughput cost neither
implementation accepts -- so both have some staleness window by design;
the gap between the two windows is what's disclosed, not a claim either
is fully live.
2026-08-14 23:47:25 -07:00

224 lines
9.2 KiB
Rust

use anyhow::{Context, Result};
use std::collections::HashSet;
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::RwLock;
/// How often the tracker re-fetches the active-tenant list after its
/// first successful fetch. Not configurable -- no deployment has needed
/// to tune this yet, and a hardcoded value keeps Config's surface
/// smaller; revisit if that changes.
const REFRESH_INTERVAL: Duration = Duration::from_secs(60);
/// Tracks which tenant_ids are currently `active` in enterprise-auth's
/// `tenants` table, polled from a new `GET /internal/active-tenants`
/// endpoint -- this closes the one gap registry.rs's `resolve` doc
/// comment used to name: `search` (AGPL core) has no Postgres access,
/// so unlike `chwriter.Registry` (an active-tenants-only snapshot built
/// from `rbacstore.ListProvisionedDataSources` at `enterprise-ingest`
/// startup) or the read side (gated by `enterprise/internal/
/// searchclient.TenantChecker`, backed by `rbacstore.TenantIsActive`
/// directly), `consumer.rs`'s write-routing had no allowlist at all --
/// any syntactically-valid `tenant_id` on a still-valid-but-should-
/// have-been-revoked ingest credential could get an index directory
/// created for it.
///
/// Network boundary, not import boundary -- same shape
/// `ingest/internal/grpcserver`'s `TenantResolver` already uses against
/// this exact service, just Rust calling Go instead of Go calling Go,
/// and authenticated the same way `/alerting` authenticates to `/api`:
/// a long-lived RoleService Bearer credential
/// (`enterprise-auth -mint-service-token search`), not a tenant-scoped
/// one -- this tracker proves "I am the search service," never "I may
/// act as tenant X."
///
/// Off unless configured: only constructed when both
/// `ENTERPRISE_AUTH_URL` and `ENTERPRISE_AUTH_SERVICE_TOKEN` are set
/// (see config.rs). When they aren't, `consumer.rs` holds `None` and
/// skips the gate entirely -- every tagged write is routed exactly as
/// it was before this tracker existed, the same "off unless configured"
/// default every other optional integration point in this codebase
/// uses.
pub struct ActiveTenantTracker {
tenants: RwLock<HashSet<String>>,
}
impl ActiveTenantTracker {
/// Blocks until the first fetch succeeds. A cold start with
/// enterprise-auth unreachable must not silently accept every
/// tenant_id it sees -- that's the exact gap this tracker exists to
/// close -- so there is deliberately no empty-set-and-keep-going
/// fallback here; callers should refuse to start the write-routing
/// consumer at all if this returns an error. Once constructed,
/// periodic refreshes are best-effort: a transient failure logs and
/// keeps serving the last-known-good set rather than clearing it
/// (see the spawned task below) -- only the very first fetch is
/// fail-closed-to-refusing-startup.
pub async fn start(base_url: &str, service_token: &str) -> Result<Arc<Self>> {
let client = reqwest::Client::new();
let initial = fetch_active_tenants(&client, base_url, service_token)
.await
.context("fetching initial active-tenant list from enterprise-auth")?;
tracing::info!(count = initial.len(), "loaded initial active-tenant list");
let tracker = Arc::new(Self {
tenants: RwLock::new(initial),
});
let refresh_tracker = Arc::clone(&tracker);
let base_url = base_url.to_string();
let service_token = service_token.to_string();
tokio::spawn(async move {
let mut ticker = tokio::time::interval(REFRESH_INTERVAL);
ticker.tick().await; // fires immediately -- start() already fetched once, skip it
loop {
ticker.tick().await;
match fetch_active_tenants(&client, &base_url, &service_token).await {
Ok(fresh) => {
let count = fresh.len();
*refresh_tracker.tenants.write().await = fresh;
tracing::debug!(count, "refreshed active-tenant list");
}
Err(e) => {
// No staleness ceiling: a prolonged enterprise-auth
// outage means the allowlist just doesn't grow or
// shrink until connectivity resumes, disclosed here
// rather than degrading further (e.g. clearing the
// set, which would stop every tenant's indexing on
// one control-plane blip -- a worse blast radius
// than staleness).
tracing::error!(error = %e, "failed to refresh active-tenant list, keeping last-known-good set");
}
}
}
});
Ok(tracker)
}
pub async fn is_active(&self, tenant_id: &str) -> bool {
self.tenants.read().await.contains(tenant_id)
}
}
#[derive(serde::Deserialize)]
struct ActiveTenantsResponse {
tenant_ids: Vec<String>,
}
async fn fetch_active_tenants(
client: &reqwest::Client,
base_url: &str,
service_token: &str,
) -> Result<HashSet<String>> {
let resp = client
.get(format!("{base_url}/internal/active-tenants"))
.bearer_auth(service_token)
.send()
.await
.context("sending request")?
.error_for_status()
.context("non-2xx response")?
.json::<ActiveTenantsResponse>()
.await
.context("parsing response body")?;
Ok(resp.tenant_ids.into_iter().collect())
}
#[cfg(test)]
mod tests {
use super::*;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpListener;
/// Minimal hand-rolled HTTP/1.1 server -- one dependency-free helper
/// rather than pulling in a mocking crate for the one endpoint this
/// module ever calls. Reads one request, hands it (as raw bytes) to
/// `respond`, writes back exactly what `respond` returns, then
/// closes -- enough to exercise real reqwest request construction
/// (the Bearer header, the URL path) and real response parsing, not
/// a fake client substituted in.
async fn spawn_fake_server(
respond: impl Fn(&str) -> String + Send + Sync + 'static,
) -> String {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
loop {
let (mut stream, _) = match listener.accept().await {
Ok(v) => v,
Err(_) => return,
};
let mut buf = vec![0u8; 8192];
let n = stream.read(&mut buf).await.unwrap_or(0);
let request = String::from_utf8_lossy(&buf[..n]).to_string();
let response = respond(&request);
let _ = stream.write_all(response.as_bytes()).await;
}
});
format!("http://{addr}")
}
fn json_response(status_line: &str, body: &str) -> String {
format!(
"{status_line}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
body.len()
)
}
#[tokio::test]
async fn start_fetches_and_serves_the_initial_list() {
let base_url = spawn_fake_server(|_req| {
json_response("HTTP/1.1 200 OK", r#"{"tenant_ids":["acme","globex"]}"#)
})
.await;
let tracker = ActiveTenantTracker::start(&base_url, "test-token")
.await
.expect("start should succeed against a healthy fake server");
assert!(tracker.is_active("acme").await);
assert!(tracker.is_active("globex").await);
assert!(!tracker.is_active("initech").await);
}
#[tokio::test]
async fn start_sends_the_bearer_token() {
let base_url = spawn_fake_server(|req| {
if req.contains("authorization: Bearer secret-token") {
json_response("HTTP/1.1 200 OK", r#"{"tenant_ids":["acme"]}"#)
} else {
json_response("HTTP/1.1 401 Unauthorized", r#"{"error":"no credentials presented"}"#)
}
})
.await;
let tracker = ActiveTenantTracker::start(&base_url, "secret-token")
.await
.expect("start should succeed once the fake server sees the right token");
assert!(tracker.is_active("acme").await);
}
#[tokio::test]
async fn start_fails_closed_when_the_first_fetch_fails() {
let base_url = spawn_fake_server(|_req| {
json_response("HTTP/1.1 401 Unauthorized", r#"{"error":"invalid or expired credentials"}"#)
})
.await;
let result = ActiveTenantTracker::start(&base_url, "wrong-token").await;
assert!(
result.is_err(),
"expected start() to fail (not silently start with an empty/permissive allowlist) when the first fetch fails"
);
}
#[tokio::test]
async fn start_fails_closed_when_the_server_is_unreachable() {
// Port 1 is (almost certainly) not listening -- connection refused,
// not a slow timeout, so this test stays fast.
let result = ActiveTenantTracker::start("http://127.0.0.1:1", "any-token").await;
assert!(result.is_err());
}
}