Skip to main content

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}