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.
224 lines
9.2 KiB
Rust
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());
|
|
}
|
|
}
|