micromegas_analytics/lakehouse/query_deny_list.rs
1//! Admin-managed query deny list: a small set of admin-authored rules, stored in Postgres, cached
2//! in every `flight-sql-srv` replica, and evaluated at the front of `execute_query` -- before the
3//! session context is built and before planning -- so a matching query is rejected for a few
4//! microseconds of work instead of a memory-pool reservation and a wave of object-store reads.
5//!
6//! A rule is a boolean SQL expression over a fixed, documented match context ([`crate::lakehouse::query_deny_list::match_schema`])
7//! -- parsed and evaluated by DataFusion itself, so there is no grammar of our own to specify or
8//! keep in sync, and no evaluator of our own to get subtly wrong. Rules stand until an admin
9//! removes them explicitly; there is no expiry.
10
11use anyhow::{Context, Result};
12use chrono::{DateTime, Utc};
13use datafusion::arrow::array::{Array, ArrayRef, BooleanArray, RecordBatch, StringArray};
14use datafusion::arrow::datatypes::{DataType, Field, Schema};
15use datafusion::common::{DFSchema, plan_err};
16use datafusion::execution::context::SessionContext;
17use datafusion::logical_expr::{ColumnarValue, ExprSchemable};
18use datafusion::physical_plan::PhysicalExpr;
19use datafusion::sql::sqlparser::dialect::GenericDialect;
20use datafusion::sql::sqlparser::tokenizer::{Token, Tokenizer};
21use micromegas_tracing::prelude::*;
22use sqlx::Row;
23use std::future::Future;
24use std::sync::Arc;
25use std::sync::LazyLock;
26use std::sync::RwLock;
27use std::sync::atomic::{AtomicI64, Ordering};
28use std::time::Duration;
29use uuid::Uuid;
30
31/// `{prefix}`-less env var: snapshot refresh / `last_hit_at` flush interval, and the bound on
32/// cross-replica propagation of a newly inserted or removed rule.
33pub const MICROMEGAS_QUERY_DENY_REFRESH_SECONDS: &str = "MICROMEGAS_QUERY_DENY_REFRESH_SECONDS";
34/// Default value of [`MICROMEGAS_QUERY_DENY_REFRESH_SECONDS`].
35pub const DEFAULT_REFRESH_SECONDS: u64 = 10;
36/// Env var: cap on the number of rules in force at once (bounds per-query evaluation cost).
37pub const MICROMEGAS_QUERY_DENY_MAX_RULES: &str = "MICROMEGAS_QUERY_DENY_MAX_RULES";
38/// Default value of [`MICROMEGAS_QUERY_DENY_MAX_RULES`].
39pub const DEFAULT_MAX_RULES: usize = 100;
40
41fn refresh_interval() -> Duration {
42 let secs = std::env::var(MICROMEGAS_QUERY_DENY_REFRESH_SECONDS)
43 .ok()
44 .and_then(|s| s.parse::<u64>().ok())
45 .unwrap_or(DEFAULT_REFRESH_SECONDS)
46 .max(1);
47 Duration::from_secs(secs)
48}
49
50/// The cap on rules in force at once, from [`MICROMEGAS_QUERY_DENY_MAX_RULES`] (default
51/// [`DEFAULT_MAX_RULES`]).
52pub fn max_rules() -> usize {
53 std::env::var(MICROMEGAS_QUERY_DENY_MAX_RULES)
54 .ok()
55 .and_then(|s| s.parse::<usize>().ok())
56 .unwrap_or(DEFAULT_MAX_RULES)
57}
58
59// ---------------------------------------------------------------------------------------------
60// Normalized SQL fingerprint
61// ---------------------------------------------------------------------------------------------
62
63/// Literal-stripped fingerprint of a statement: the first 16 hex chars of the SHA-256 of the
64/// normalized token stream. Tokenizes internally; the token stream is an implementation detail
65/// and does not appear in the signature.
66///
67/// This is what makes the dashboard case work: consecutive refreshes of the same panel differ
68/// only in their time-range literals, so they collapse to one fingerprint. Tokenization failure
69/// (unparseable SQL) falls back to hashing the whitespace-collapsed raw text, so a fingerprint
70/// always exists.
71pub fn fingerprint_of(sql: &str) -> String {
72 let normalized = match Tokenizer::new(&GenericDialect {}, sql).tokenize() {
73 Ok(tokens) => normalize_tokens(&tokens),
74 Err(_) => collapse_whitespace(sql),
75 };
76 hash_hex16(&normalized)
77}
78
79/// Joins the normalized token text with a single space: every `Token::Number`/string-literal
80/// variant becomes `?`; whitespace and comments (both represented as `Token::Whitespace`) are
81/// dropped; a `Token::Word` is lowercased only when it carries no `quote_style` -- a quoted word
82/// (e.g. `"processes.exe"`) is kept verbatim, case included, via its own `Display` impl, which is
83/// what keeps two differently-cased quoted identifiers from colliding into the same fingerprint.
84/// Everything else uses the token's own `Display` impl unchanged.
85fn normalize_tokens(tokens: &[Token]) -> String {
86 let mut parts: Vec<String> = Vec::with_capacity(tokens.len());
87 for tok in tokens {
88 match tok {
89 Token::Whitespace(_) => continue,
90 Token::Number(_, _)
91 | Token::SingleQuotedString(_)
92 | Token::NationalStringLiteral(_)
93 | Token::HexStringLiteral(_)
94 | Token::EscapedStringLiteral(_)
95 | Token::TripleSingleQuotedString(_)
96 | Token::TripleDoubleQuotedString(_) => parts.push("?".to_string()),
97 Token::Word(w) if w.quote_style.is_none() => parts.push(w.value.to_lowercase()),
98 other => parts.push(other.to_string()),
99 }
100 }
101 parts.join(" ")
102}
103
104fn collapse_whitespace(sql: &str) -> String {
105 sql.split_whitespace().collect::<Vec<_>>().join(" ")
106}
107
108fn hash_hex16(s: &str) -> String {
109 use sha2::{Digest, Sha256};
110 let digest = Sha256::digest(s.as_bytes());
111 let mut hex = hex::encode(digest);
112 hex.truncate(16);
113 hex
114}
115
116// ---------------------------------------------------------------------------------------------
117// The match context and expression compilation
118// ---------------------------------------------------------------------------------------------
119
120/// The fixed, documented schema every deny rule is a predicate over -- every attribute
121/// `execute_query` has already resolved by the time the check runs, in the same column order as
122/// [`QueryAttribution::to_batch`]. Every field is nullable `Utf8`: NULL semantics come from SQL
123/// and are the ones we want (e.g. `notebook = 'fleet-overview'` evaluates to NULL, not true, for
124/// a query that carried no notebook header, so the rule does not fire).
125///
126/// The identity column is named `user_id`, not `user`: DataFusion's default SQL dialect parses a
127/// bare `user` in expression position as the zero-argument function call `user()`, not a column
128/// reference, and no such scalar function is registered here -- `user = 'jean'` would fail at
129/// planning with "Invalid function 'user'".
130///
131/// Built once, in [`MATCH_SCHEMA`]: constructing a `DFSchema` runs `check_names()` (a `BTreeSet`
132/// insert per field) on top of allocating the `Fields`/`Schema` themselves, and this is called on
133/// every query while any deny rule stands -- so `match_schema()` and [`QueryAttribution::to_batch`]
134/// both clone the cached `DFSchema`/`Arc<Schema>` rather than rebuilding it.
135static MATCH_SCHEMA: LazyLock<DFSchema> = LazyLock::new(|| {
136 let arrow_schema = Schema::new(vec![
137 Field::new("user_id", DataType::Utf8, true),
138 Field::new("email", DataType::Utf8, true),
139 Field::new("service_account", DataType::Utf8, true),
140 Field::new("client", DataType::Utf8, true),
141 Field::new("agent", DataType::Utf8, true),
142 Field::new("entrypoint", DataType::Utf8, true),
143 Field::new("session", DataType::Utf8, true),
144 Field::new("notebook", DataType::Utf8, true),
145 Field::new("cell", DataType::Utf8, true),
146 Field::new("client_ip", DataType::Utf8, true),
147 Field::new("sql", DataType::Utf8, true),
148 Field::new("sql_hash", DataType::Utf8, true),
149 ]);
150 DFSchema::try_from(arrow_schema).expect("match_schema is a fixed, valid schema")
151});
152
153pub fn match_schema() -> DFSchema {
154 MATCH_SCHEMA.clone()
155}
156
157/// Borrowed view of what `execute_query` has already resolved, in the same column order as
158/// [`match_schema`]. `service_account`/`session`/`notebook`/`cell` are `Option` because the
159/// corresponding header may be absent; every other field always has a value (falling back to
160/// `"unknown"` upstream when nothing better is available).
161pub struct QueryAttribution<'a> {
162 pub user_id: &'a str,
163 pub email: &'a str,
164 pub service_account: Option<&'a str>,
165 pub client: &'a str,
166 pub agent: &'a str,
167 pub entrypoint: &'a str,
168 pub session: Option<&'a str>,
169 pub notebook: Option<&'a str>,
170 pub cell: Option<&'a str>,
171 pub client_ip: &'a str,
172 pub sql: &'a str,
173 pub sql_hash: &'a str,
174}
175
176impl QueryAttribution<'static> {
177 /// A fixed, fully-populated attribution used only to canary-evaluate a compiled expression
178 /// once at compile time (see [`compile_match_expr`]'s step 4) -- its values are never
179 /// meaningful, only whether comparing against them errors.
180 fn probe() -> Self {
181 Self {
182 user_id: "probe",
183 email: "probe",
184 service_account: Some("probe"),
185 client: "probe",
186 agent: "probe",
187 entrypoint: "probe",
188 session: Some("probe"),
189 notebook: Some("probe"),
190 cell: Some("probe"),
191 client_ip: "probe",
192 sql: "probe",
193 sql_hash: "probe",
194 }
195 }
196}
197
198impl QueryAttribution<'_> {
199 /// One-row `RecordBatch` over [`match_schema`], in column order -- the only thing
200 /// [`QueryDenyList::check`] builds per query, and the only allocation on the deny-check path.
201 pub fn to_batch(&self) -> RecordBatch {
202 let schema = MATCH_SCHEMA.inner().clone();
203 let arrays: Vec<ArrayRef> = vec![
204 Arc::new(StringArray::from(vec![Some(self.user_id)])),
205 Arc::new(StringArray::from(vec![Some(self.email)])),
206 Arc::new(StringArray::from(vec![self.service_account])),
207 Arc::new(StringArray::from(vec![Some(self.client)])),
208 Arc::new(StringArray::from(vec![Some(self.agent)])),
209 Arc::new(StringArray::from(vec![Some(self.entrypoint)])),
210 Arc::new(StringArray::from(vec![self.session])),
211 Arc::new(StringArray::from(vec![self.notebook])),
212 Arc::new(StringArray::from(vec![self.cell])),
213 Arc::new(StringArray::from(vec![Some(self.client_ip)])),
214 Arc::new(StringArray::from(vec![Some(self.sql)])),
215 Arc::new(StringArray::from(vec![Some(self.sql_hash)])),
216 ];
217 RecordBatch::try_new(schema, arrays)
218 .expect("match_schema and QueryAttribution::to_batch are kept in sync")
219 }
220}
221
222/// Parses, validates, and plans `match_expr` into an `Arc<dyn PhysicalExpr>` -- the whole
223/// evaluator, correct by construction because DataFusion does the parsing and the evaluation.
224///
225/// 1. **Parse** with `ctx.parse_sql_expr`, against [`match_schema`]. `ctx` is expected to be a
226/// bare `SessionContext::new()` (no lakehouse tables or catalog registered) -- see
227/// [`QueryDenyList`]'s doc comment for why the same bare context is used both at refresh and
228/// from `deny_queries`.
229/// 2. **Validate** with exactly two checks: the result type is `Boolean`, and at least one column
230/// is referenced. An unknown column or function already fails at step 1; a subquery,
231/// aggregate, or window function cannot be lowered to a scalar `PhysicalExpr` and fails at
232/// step 3 -- each with DataFusion's own diagnostic.
233/// 3. **Plan** the validated `Expr` into a `PhysicalExpr` with the free-function
234/// `datafusion::physical_expr::create_physical_expr` -- deliberately *not*
235/// `SessionContext::create_physical_expr`/`SessionState::create_physical_expr`, both of which
236/// run the expression through `ExprSimplifier::coerce` first and would silently insert a
237/// `CAST` for a type-mismatched comparison (`client = 42` becomes `CAST(client AS Int64) =
238/// 42`, which compiles, then either errors on every real value or -- worse -- happens to
239/// parse and quietly matches something the admin never intended). The free function's own
240/// doc comment is explicit that it performs no coercion ("There should be no coercion during
241/// physical planning") -- but it also performs no operand-type *checking* at this stage:
242/// `binary()` happily builds a `BinaryExpr` node comparing a `Utf8` column against an
243/// `Int64`/`Timestamp` literal, and Arrow's comparison kernels are the ones that actually
244/// refuse it, only once the node is evaluated. Left there, that would be exactly the "silent
245/// per-query failure" this design wants to avoid -- so step 4 forces that evaluation now.
246/// 4. **Canary-evaluate** the compiled expression once against `QueryAttribution::probe`, a
247/// fixed one-row batch that exists for no other reason. Every match-context column is
248/// `Utf8`, so the only way this can fail is the type mismatch step 3 doesn't catch; catching
249/// it here turns "never fires" into a loud compile-time error, at the one-time cost of a
250/// single evaluate() call per rule (refresh/`deny_queries` time, never per query).
251pub fn compile_match_expr(
252 ctx: &SessionContext,
253 match_expr: &str,
254) -> datafusion::error::Result<Arc<dyn PhysicalExpr>> {
255 let schema = match_schema();
256 let expr = ctx.parse_sql_expr(match_expr, &schema)?;
257 let ty = expr.get_type(&schema)?;
258 if ty != DataType::Boolean {
259 return plan_err!("query deny rule must be a boolean expression, got {ty}: {match_expr}");
260 }
261 if expr.column_refs().is_empty() {
262 return plan_err!(
263 "query deny rule must reference at least one match-context column -- a rule with \
264 no column reference would deny every query in the deployment: {match_expr}"
265 );
266 }
267 let physical = datafusion::physical_expr::create_physical_expr(
268 &expr,
269 &schema,
270 &datafusion::execution::context::ExecutionProps::new(),
271 &datafusion::logical_expr::physical_planning_context::PhysicalPlanningContext::default(),
272 )?;
273 let probe_batch = QueryAttribution::probe().to_batch();
274 physical.evaluate(&probe_batch).map_err(|e| {
275 datafusion::error::DataFusionError::Plan(format!(
276 "query deny rule failed a compile-time check, likely comparing a column against a \
277 mismatched type: {e}: {match_expr}"
278 ))
279 })?;
280 Ok(physical)
281}
282
283/// The primary escape hatch: the deny-list check is skipped when the caller can reach
284/// the admin functions at all -- the same predicate `register_lakehouse_functions` gates
285/// `deny_queries`/`remove_query_denial` on -- **and** the statement mentions one of
286/// `deny_queries` / `remove_query_denial` / `list_query_denials`: three `contains` checks over
287/// the lowercased SQL text, nothing more. No call-position or token analysis: this only ever
288/// runs for a query that is *already* being denied, and the gate it sits behind means any caller
289/// it opens for could call those three functions directly anyway.
290pub fn skip_for_admin_recovery(sql: &str, is_admin: bool) -> bool {
291 if !is_admin {
292 return false;
293 }
294 let lowered = sql.to_lowercase();
295 lowered.contains("deny_queries")
296 || lowered.contains("remove_query_denial")
297 || lowered.contains("list_query_denials")
298}
299
300// ---------------------------------------------------------------------------------------------
301// Rule model, evaluation, and cache
302// ---------------------------------------------------------------------------------------------
303
304/// The DB row, exactly as `list_query_denials()` returns it and as `insert` echoes back. No
305/// compiled expression or in-process timestamp here -- those exist only for a rule that has been
306/// compiled into a [`DenySnapshot`], and a freshly inserted or listed row has not necessarily
307/// been through that yet on every replica.
308#[derive(Debug, Clone)]
309pub struct QueryDenyRow {
310 pub rule_id: Uuid,
311 pub created_at: DateTime<Utc>,
312 pub created_by: String,
313 pub reason: String,
314 pub match_expr: String,
315 /// `None` until the rule first fires; accurate to within one refresh tick.
316 pub last_hit_at: Option<DateTime<Utc>>,
317}
318
319/// A row compiled into one snapshot: the row, its planned expression, and the one piece of
320/// in-process state a rule accumulates. Lives only inside a [`DenySnapshot`].
321#[derive(Debug)]
322pub struct QueryDenyRule {
323 pub row: QueryDenyRow,
324 expr: Arc<dyn PhysicalExpr>,
325 /// Unix seconds of the most recent hit since the last flush; `0` means "not hit since the
326 /// last flush". Flushed (and reset to `0`) by [`QueryDenyList::refresh`].
327 last_hit: AtomicI64,
328}
329
330impl QueryDenyRule {
331 /// `pub` so tests can build a rule directly from a [`compile_match_expr`] result, without
332 /// going through Postgres -- see [`QueryDenyList::with_snapshot`].
333 pub fn new(row: QueryDenyRow, expr: Arc<dyn PhysicalExpr>) -> Self {
334 Self {
335 row,
336 expr,
337 last_hit: AtomicI64::new(0),
338 }
339 }
340
341 /// Records that this rule matched a query, for the next [`QueryDenyList::refresh`] tick to
342 /// flush into `last_hit_at`. Racing hits within one tick are fine -- `fetch_max` keeps the
343 /// most recent, and that's all "is this rule still firing?" needs.
344 pub fn record_hit(&self) {
345 let now = Utc::now().timestamp();
346 self.last_hit.fetch_max(now, Ordering::Relaxed);
347 }
348}
349
350/// The compiled rules, ordered by `(created_at, rule_id)`, oldest first -- so the first match is
351/// always the oldest matching rule, on every replica. An alias, not a newtype: nothing hangs
352/// behind it. `Arc<[_]>` rather than `Arc<Vec<_>>`:
353/// the snapshot is immutable once built, and this drops a pointer hop off the per-query path.
354pub type DenySnapshot = Arc<[Arc<QueryDenyRule>]>;
355
356fn empty_snapshot() -> DenySnapshot {
357 Arc::from(Vec::new())
358}
359
360/// Sorts `rules` by `(created_at, rule_id)`, oldest first, and wraps them into a [`DenySnapshot`]
361/// -- `pub` so tests can build one directly, matching the invariant [`QueryDenyList::refresh`]
362/// and [`QueryDenyList::insert`] maintain internally.
363pub fn sorted_snapshot(mut rules: Vec<Arc<QueryDenyRule>>) -> DenySnapshot {
364 rules.sort_by_key(|r| (r.row.created_at, r.row.rule_id));
365 Arc::from(rules)
366}
367
368/// Owns the Postgres-backed rule store and the compiled, in-memory snapshot every query is
369/// checked against. One instance is owned by `LakehouseContext`, mirroring `AudienceIndex`.
370///
371/// `ctx` is a bare `SessionContext::new()` -- no lakehouse tables or catalog registered against
372/// it, just DataFusion's parser and name resolution -- held only so [`compile_match_expr`] can be
373/// called against it. Both `refresh` and `deny_queries`'s `call_with_args` compile through this
374/// same context, so rule compilation produces the same result regardless of whose session
375/// triggered it.
376pub struct QueryDenyList {
377 pool: sqlx::Pool<sqlx::Postgres>,
378 ctx: SessionContext,
379 snapshot: RwLock<DenySnapshot>,
380 /// Serializes `insert`/`delete`/`refresh`'s DB-operation-plus-snapshot-swap against each
381 /// other, held across the whole sequence (including the `await`s) rather than just the
382 /// final snapshot edit. Without this, `refresh` reading the table in `load_rows` and a
383 /// concurrent `delete` performing its own DELETE-plus-snapshot-edit could interleave so that
384 /// `refresh`'s stale, pre-delete row set overwrites the snapshot `delete` just fixed up,
385 /// silently re-enforcing a rule the admin was just told was removed (or the mirror case,
386 /// losing a freshly inserted rule) until the next tick. Serializing the three methods means
387 /// a `delete`/`insert` either finishes entirely before a `refresh` starts reading the table,
388 /// or starts entirely after `refresh`'s swap -- either way it sees, and leaves, a consistent
389 /// snapshot. `flush_hits` is not covered: it only updates each rule's `last_hit` in place and
390 /// never replaces the snapshot, so it cannot lose an insert/delete.
391 write_lock: tokio::sync::Mutex<()>,
392}
393
394impl std::fmt::Debug for QueryDenyList {
395 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
396 f.debug_struct("QueryDenyList")
397 .field("rules", &self.snapshot.read().expect("lock").len())
398 .finish()
399 }
400}
401
402impl QueryDenyList {
403 pub fn new(pool: sqlx::Pool<sqlx::Postgres>) -> Self {
404 Self {
405 pool,
406 ctx: SessionContext::new(),
407 snapshot: RwLock::new(empty_snapshot()),
408 write_lock: tokio::sync::Mutex::new(()),
409 }
410 }
411
412 /// Builds a `QueryDenyList` around an already-compiled `snapshot`, bypassing Postgres
413 /// entirely -- for tests exercising [`Self::check`]'s in-memory semantics without a live DB
414 /// (`pool` is unused by `check`; a `connect_lazy` pool that never touches the network is
415 /// enough). Production code should use [`Self::new`] plus [`Self::refresh`].
416 pub fn with_snapshot(pool: sqlx::Pool<sqlx::Postgres>, snapshot: DenySnapshot) -> Self {
417 Self {
418 pool,
419 ctx: SessionContext::new(),
420 snapshot: RwLock::new(snapshot),
421 write_lock: tokio::sync::Mutex::new(()),
422 }
423 }
424
425 /// A bare `SessionContext` with no lakehouse tables or catalog registered, for compiling a
426 /// `match_expr` outside this `QueryDenyList` (e.g. from `deny_queries`'s `call_with_args`,
427 /// which is handed the caller's own session and needs a SQL-expression parser instead).
428 pub fn compile_ctx(&self) -> &SessionContext {
429 &self.ctx
430 }
431
432 /// The number of rules currently compiled into the snapshot -- what `deny_queries` checks
433 /// against [`max_rules`] before inserting one more.
434 pub fn rule_count(&self) -> usize {
435 self.snapshot.read().expect("lock").len()
436 }
437
438 /// Checks `q` against every compiled rule, in order, returning the first (oldest) match.
439 /// Returns immediately on an empty snapshot -- the steady state of a deployment that is not
440 /// mid-incident -- before building the one-row batch every non-empty check needs.
441 pub fn check(&self, q: &QueryAttribution<'_>) -> Option<Arc<QueryDenyRule>> {
442 let snapshot = self.snapshot.read().expect("lock").clone();
443 if snapshot.is_empty() {
444 return None;
445 }
446 let batch = q.to_batch();
447 for rule in snapshot.iter() {
448 match rule.expr.evaluate(&batch) {
449 Ok(ColumnarValue::Array(array)) => {
450 if let Some(bools) = array.as_any().downcast_ref::<BooleanArray>()
451 && bools.len() == 1
452 && !bools.is_null(0)
453 && bools.value(0)
454 {
455 return Some(rule.clone());
456 }
457 }
458 Ok(ColumnarValue::Scalar(datafusion::scalar::ScalarValue::Boolean(Some(true)))) => {
459 return Some(rule.clone());
460 }
461 Ok(ColumnarValue::Scalar(_)) => {}
462 Err(e) => {
463 warn!(
464 "query_deny_list: rule {} failed to evaluate, skipping: {e:#}",
465 rule.row.rule_id
466 );
467 }
468 }
469 }
470 None
471 }
472
473 async fn load_rows(&self) -> Result<Vec<QueryDenyRow>> {
474 let records = sqlx::query(
475 "SELECT rule_id, created_at, created_by, reason, match_expr, last_hit_at \
476 FROM query_deny_list \
477 ORDER BY created_at, rule_id",
478 )
479 .fetch_all(&self.pool)
480 .await
481 .context("loading query_deny_list rows")?;
482 records
483 .into_iter()
484 .map(|row| {
485 Ok(QueryDenyRow {
486 rule_id: row.try_get("rule_id")?,
487 created_at: row.try_get("created_at")?,
488 created_by: row.try_get("created_by")?,
489 reason: row.try_get("reason")?,
490 match_expr: row.try_get("match_expr")?,
491 last_hit_at: row.try_get("last_hit_at")?,
492 })
493 })
494 .collect()
495 }
496
497 /// Every rule currently in force, read fresh from Postgres (not the in-memory snapshot) --
498 /// what `list_query_denials()` returns.
499 pub async fn list(&self) -> Result<Vec<QueryDenyRow>> {
500 self.load_rows().await
501 }
502
503 /// Inserts a new rule: `compiled` must already be the result of a successful
504 /// [`compile_match_expr`] call against `match_expr` (typically the same call
505 /// `deny_queries`'s `call_with_args` just made to validate it) -- `insert` stores
506 /// `match_expr` verbatim and does not re-validate it. Refreshes the local snapshot
507 /// synchronously before returning, so the admin who created the rule sees it in their own
508 /// `list_query_denials()` immediately; other replicas pick it up within one refresh tick.
509 pub async fn insert(
510 &self,
511 match_expr: &str,
512 compiled: Arc<dyn PhysicalExpr>,
513 reason: &str,
514 created_by: &str,
515 ) -> Result<QueryDenyRow> {
516 let row = QueryDenyRow {
517 rule_id: Uuid::new_v4(),
518 created_at: Utc::now(),
519 created_by: created_by.to_string(),
520 reason: reason.to_string(),
521 match_expr: match_expr.to_string(),
522 last_hit_at: None,
523 };
524 // Held across the INSERT and the snapshot edit -- see `write_lock`'s doc comment -- so a
525 // concurrent `refresh` cannot read the table before this INSERT and then overwrite the
526 // snapshot edit below with that stale, pre-insert row set.
527 let _guard = self.write_lock.lock().await;
528 sqlx::query(
529 "INSERT INTO query_deny_list (rule_id, created_at, created_by, reason, match_expr) \
530 VALUES ($1, $2, $3, $4, $5)",
531 )
532 .bind(row.rule_id)
533 .bind(row.created_at)
534 .bind(&row.created_by)
535 .bind(&row.reason)
536 .bind(&row.match_expr)
537 .execute(&self.pool)
538 .await
539 .context("inserting query_deny_list row")?;
540 let rule = Arc::new(QueryDenyRule::new(row.clone(), compiled));
541 {
542 let mut snapshot = self.snapshot.write().expect("lock");
543 let mut rules: Vec<Arc<QueryDenyRule>> = snapshot.iter().cloned().collect();
544 rules.push(rule);
545 *snapshot = sorted_snapshot(rules);
546 }
547 Ok(row)
548 }
549
550 /// Deletes a rule by id, refreshing the local snapshot synchronously. Returns `true` if a row
551 /// was actually deleted -- `false` lets the caller (`remove_query_denial`) return a clear
552 /// "no such rule" message rather than a silent no-op.
553 pub async fn delete(&self, rule_id: Uuid) -> Result<bool> {
554 // Held across the DELETE and the snapshot edit -- see `write_lock`'s doc comment -- so a
555 // concurrent `refresh` cannot read the table before this DELETE and then overwrite the
556 // snapshot edit below with that stale, pre-delete row set.
557 let _guard = self.write_lock.lock().await;
558 let result = sqlx::query("DELETE FROM query_deny_list WHERE rule_id = $1")
559 .bind(rule_id)
560 .execute(&self.pool)
561 .await
562 .context("deleting query_deny_list row")?;
563 let deleted = result.rows_affected() > 0;
564 if deleted {
565 let mut snapshot = self.snapshot.write().expect("lock");
566 let rules: Vec<Arc<QueryDenyRule>> = snapshot
567 .iter()
568 .filter(|rule| rule.row.rule_id != rule_id)
569 .cloned()
570 .collect();
571 *snapshot = Arc::from(rules);
572 }
573 Ok(deleted)
574 }
575
576 /// Flushes each rule's accumulated `last_hit`, then reloads the table and recompiles every
577 /// `match_expr`. **A rule that fails to compile is skipped, never fatal** -- dropped from the
578 /// snapshot with a `warn!` and a `query_deny_compile_error_count` metric, so an older replica
579 /// that cannot compile a newer rule declines to enforce it rather than denying everything or
580 /// crashing. **Fail-open on a failed reload**: the previous snapshot is kept, with a `warn!`
581 /// and a `query_deny_refresh_error_count` metric -- the deny list is an availability valve,
582 /// not a security control, and failing closed would deny every query on a Postgres blip.
583 pub async fn refresh(&self) -> Result<()> {
584 self.flush_hits().await;
585 // Held from before `load_rows` through the final snapshot swap below -- see
586 // `write_lock`'s doc comment -- so a concurrent `insert`/`delete` cannot land its own
587 // DB-op-plus-snapshot-edit inside that window and have it clobbered by this stale read.
588 let _guard = self.write_lock.lock().await;
589 let rows = match self.load_rows().await {
590 Ok(rows) => rows,
591 Err(e) => {
592 imetric!("query_deny_refresh_error_count", "count", 1_u64);
593 warn!("query_deny_list: refresh failed, keeping previous snapshot: {e:#}");
594 return Err(e);
595 }
596 };
597 let mut compiled = Vec::with_capacity(rows.len());
598 for row in rows {
599 let rule_id = row.rule_id;
600 match compile_match_expr(&self.ctx, &row.match_expr) {
601 Ok(expr) => compiled.push(Arc::new(QueryDenyRule::new(row, expr))),
602 Err(e) => {
603 imetric!(
604 "query_deny_compile_error_count",
605 "count",
606 rule_id = %rule_id,
607 1_u64
608 );
609 warn!(
610 "query_deny_list: rule {rule_id} failed to compile, skipping (not \
611 enforced): {e:#}"
612 );
613 }
614 }
615 }
616 {
617 // Carry each new rule's `last_hit` forward from its predecessor in the outgoing
618 // snapshot, so a `record_hit()` landing on the old `Arc` between `flush_hits` and this
619 // swap (two DB round-trips plus the compile loop above) isn't discarded along with it.
620 // Held across the read-merge-write so the window in which such a hit could still be
621 // lost is just the in-memory work below, not the whole refresh.
622 let mut snapshot = self.snapshot.write().expect("lock");
623 for rule in &compiled {
624 if let Some(previous) = snapshot.iter().find(|p| p.row.rule_id == rule.row.rule_id)
625 {
626 let carried = previous.last_hit.load(Ordering::Relaxed);
627 rule.last_hit.fetch_max(carried, Ordering::Relaxed);
628 }
629 }
630 *snapshot = sorted_snapshot(compiled);
631 }
632 Ok(())
633 }
634
635 async fn flush_hits(&self) {
636 let snapshot = self.snapshot.read().expect("lock").clone();
637 for rule in snapshot.iter() {
638 let last_hit = rule.last_hit.swap(0, Ordering::Relaxed);
639 if last_hit == 0 {
640 continue;
641 }
642 let hit_at = DateTime::<Utc>::from_timestamp(last_hit, 0).unwrap_or_else(Utc::now);
643 if let Err(e) = sqlx::query(
644 "UPDATE query_deny_list \
645 SET last_hit_at = greatest(coalesce(last_hit_at, $1), $1) \
646 WHERE rule_id = $2",
647 )
648 .bind(hit_at)
649 .bind(rule.row.rule_id)
650 .execute(&self.pool)
651 .await
652 {
653 // Re-arm rather than leaving the atomic at the `swap(0)` above: a failed UPDATE
654 // must not permanently lose the hit, since it's the next tick's only chance to
655 // retry the write. `fetch_max` so a hit recorded concurrently (after our swap,
656 // above) isn't clobbered by this re-arm.
657 rule.last_hit.fetch_max(last_hit, Ordering::Relaxed);
658 warn!(
659 "query_deny_list: failed to flush last_hit for rule {}: {e:#}",
660 rule.row.rule_id
661 );
662 }
663 }
664 }
665
666 /// Spawns the background task that calls [`Self::refresh`] once immediately and then on
667 /// every [`MICROMEGAS_QUERY_DENY_REFRESH_SECONDS`] tick, until `shutdown` resolves. Only the
668 /// FlightSQL server builder spawns this; other `LakehouseContext` holders (maintenance
669 /// daemon, tests) keep an empty snapshot and never deny anything.
670 pub fn spawn_refresh_task(
671 self: Arc<Self>,
672 shutdown: impl Future<Output = ()> + Send + 'static,
673 ) {
674 tokio::spawn(async move {
675 let mut shutdown = Box::pin(shutdown);
676 loop {
677 // Ignore the `Err`: `refresh()` has already warn!-logged and metered a failed
678 // reload before returning it.
679 let _ = self.refresh().await;
680 tokio::select! {
681 _ = &mut shutdown => break,
682 _ = tokio::time::sleep(refresh_interval()) => {}
683 }
684 }
685 });
686 }
687}