Skip to main content

chio_data_guards/
config.rs

1//! Configuration types for the SQL query guard.
2//!
3//! [`SqlGuardConfig`] captures the four enforcement knobs defined by phase
4//! 7.1 of the roadmap:
5//!
6//! - `operation_allowlist`: which SQL operations are permitted
7//!   (`SELECT`, `INSERT`, `UPDATE`, `DELETE`, DDL, other).
8//! - `table_allowlist`: which tables may be referenced (case-insensitive).
9//! - `column_allowlist`: optional per-table restriction on projected columns.
10//! - `denylisted_predicates`: regex patterns matched against canonicalized
11//!   WHERE clauses (for example to block `OR 1=1` style injections).
12//!
13//! The guard is fail-closed by default: an empty config denies every query.
14//! Operators can opt into an open configuration via [`SqlGuardConfig::allow_all`],
15//! which the guard logs as a warning on construction.
16
17use std::collections::HashMap;
18
19use serde::{Deserialize, Serialize};
20
21/// SQL dialect selector for [`sqlparser`].
22///
23/// We keep our own enum rather than re-exporting [`sqlparser::dialect::Dialect`]
24/// so the public config type is `Deserialize` and does not leak the parser
25/// crate's trait objects into every caller.
26#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
27#[serde(rename_all = "snake_case")]
28pub enum SqlDialect {
29    /// Generic ANSI-ish dialect.  The default.
30    #[default]
31    Generic,
32    /// PostgreSQL.
33    Postgres,
34    /// MySQL.
35    MySql,
36    /// SQLite.
37    Sqlite,
38    /// Microsoft SQL Server (T-SQL).
39    MsSql,
40    /// Snowflake.
41    Snowflake,
42    /// BigQuery.
43    BigQuery,
44}
45
46/// Normalized SQL operation class tracked by the guard.
47///
48/// This is a coarser classification than [`sqlparser::ast::Statement`]: every
49/// statement maps to exactly one of these variants.  Guards compare against
50/// this enum so callers can write dialect-independent policy.
51#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
52#[serde(rename_all = "snake_case")]
53pub enum SqlOperation {
54    /// `SELECT`, `WITH ... SELECT`, and other read-only queries.
55    Select,
56    /// `INSERT`.
57    Insert,
58    /// `UPDATE`.
59    Update,
60    /// `DELETE`, `TRUNCATE`.
61    Delete,
62    /// DDL: `CREATE`, `DROP`, `ALTER`, `RENAME`, `COMMENT`.
63    Ddl,
64    /// Anything that does not fit a category above (for example `EXPLAIN`,
65    /// `SET`, `SHOW`).  Fail-closed: allowlist must explicitly include
66    /// [`SqlOperation::Other`] for these.
67    Other,
68}
69
70impl SqlOperation {
71    /// Stable string tag used by logs and denial reasons.
72    pub fn as_str(self) -> &'static str {
73        match self {
74            Self::Select => "SELECT",
75            Self::Insert => "INSERT",
76            Self::Update => "UPDATE",
77            Self::Delete => "DELETE",
78            Self::Ddl => "DDL",
79            Self::Other => "OTHER",
80        }
81    }
82}
83
84/// Guard configuration for [`SqlQueryGuard`](crate::sql_guard::SqlQueryGuard).
85///
86/// The guard is fail-closed: when every list is empty and `allow_all` is
87/// false, the guard denies every SQL query.
88#[derive(Clone, Debug, Serialize, Deserialize)]
89pub struct SqlGuardConfig {
90    /// SQL dialect used by the parser.  Defaults to [`SqlDialect::Generic`].
91    #[serde(default)]
92    pub dialect: SqlDialect,
93
94    /// Operations that are permitted.  A query whose parsed
95    /// [`SqlOperation`] is not in this list is denied.
96    #[serde(default)]
97    pub operation_allowlist: Vec<SqlOperation>,
98
99    /// Tables that may be referenced in `FROM`, `JOIN`, `INSERT INTO`,
100    /// `UPDATE`, and `DELETE FROM`.  Comparisons are case-insensitive.
101    #[serde(default)]
102    pub table_allowlist: Vec<String>,
103
104    /// Optional per-table projected-column allowlist.  When set, every
105    /// column projected in a `SELECT` on the table must appear here.  A
106    /// table that does not appear as a key is treated as having no column
107    /// restriction.  `SELECT *` is denied whenever the referenced table has
108    /// a column allowlist entry.
109    #[serde(default)]
110    pub column_allowlist: Option<HashMap<String, Vec<String>>>,
111
112    /// Regex patterns matched against the canonicalized WHERE clause text
113    /// of each query.  A match denies the query.
114    #[serde(default)]
115    pub denylisted_predicates: Vec<String>,
116
117    /// Deny mutations (`UPDATE`, `DELETE`) that lack a `WHERE` clause.
118    /// Defaults to `true` (roadmap 7.1 acceptance criterion).
119    #[serde(default = "default_require_where_for_mutations")]
120    pub require_where_for_mutations: bool,
121
122    /// Escape hatch: allow every query that parses successfully.
123    ///
124    /// This overrides the fail-closed default.  The guard logs a warning
125    /// on construction when `allow_all` is true so operators can find the
126    /// escape hatch in observability.  Malformed SQL is still denied: the
127    /// parse error wins over `allow_all`.
128    #[serde(default)]
129    pub allow_all: bool,
130}
131
132fn default_require_where_for_mutations() -> bool {
133    true
134}
135
136impl Default for SqlGuardConfig {
137    fn default() -> Self {
138        Self {
139            dialect: SqlDialect::default(),
140            operation_allowlist: Vec::new(),
141            table_allowlist: Vec::new(),
142            column_allowlist: None,
143            denylisted_predicates: Vec::new(),
144            require_where_for_mutations: default_require_where_for_mutations(),
145            allow_all: false,
146        }
147    }
148}
149
150impl SqlGuardConfig {
151    /// Returns true when every allowlist is empty.  The guard treats this
152    /// as "no config" and denies every query unless `allow_all` is set.
153    pub fn is_empty(&self) -> bool {
154        self.operation_allowlist.is_empty()
155            && self.table_allowlist.is_empty()
156            && self
157                .column_allowlist
158                .as_ref()
159                .map(|m| m.is_empty())
160                .unwrap_or(true)
161            && self.denylisted_predicates.is_empty()
162    }
163
164    /// Case-insensitive lookup of a table in the allowlist.
165    pub fn table_allowed(&self, name: &str) -> bool {
166        let lower = name.to_ascii_lowercase();
167        self.table_allowlist
168            .iter()
169            .any(|entry| entry.to_ascii_lowercase() == lower)
170    }
171
172    /// Case-insensitive lookup of a column on the given table.  Returns
173    /// `None` when no column allowlist is configured, `Some(true)` when the
174    /// column is allowed, and `Some(false)` when it is denied.
175    pub fn column_allowed(&self, table: &str, column: &str) -> Option<bool> {
176        let map = self.column_allowlist.as_ref()?;
177        let lower_table = table.to_ascii_lowercase();
178        let lower_column = column.to_ascii_lowercase();
179        for (tbl, cols) in map {
180            if tbl.to_ascii_lowercase() == lower_table {
181                let allowed = cols
182                    .iter()
183                    .any(|c| c.to_ascii_lowercase() == lower_column || c == "*");
184                return Some(allowed);
185            }
186        }
187        None
188    }
189
190    /// Returns true when the table has an explicit column allowlist entry.
191    /// Used to decide whether `SELECT *` should be denied.
192    pub fn table_has_column_allowlist(&self, table: &str) -> bool {
193        let Some(map) = self.column_allowlist.as_ref() else {
194            return false;
195        };
196        let lower = table.to_ascii_lowercase();
197        map.keys().any(|k| k.to_ascii_lowercase() == lower)
198    }
199}
200
201#[cfg(test)]
202mod tests {
203    use super::*;
204
205    #[test]
206    fn empty_config_is_detected() {
207        let cfg = SqlGuardConfig::default();
208        assert!(cfg.is_empty());
209    }
210
211    #[test]
212    fn table_allowlist_is_case_insensitive() {
213        let cfg = SqlGuardConfig {
214            table_allowlist: vec!["Orders".to_string()],
215            ..Default::default()
216        };
217        assert!(cfg.table_allowed("orders"));
218        assert!(cfg.table_allowed("ORDERS"));
219        assert!(!cfg.table_allowed("users"));
220    }
221
222    #[test]
223    fn column_allowlist_returns_none_when_unset() {
224        let cfg = SqlGuardConfig::default();
225        assert!(cfg.column_allowed("orders", "id").is_none());
226    }
227
228    #[test]
229    fn column_allowlist_hit_and_miss() {
230        let mut map = HashMap::new();
231        map.insert("orders".to_string(), vec!["id".to_string(), "total".into()]);
232        let cfg = SqlGuardConfig {
233            column_allowlist: Some(map),
234            ..Default::default()
235        };
236        assert_eq!(cfg.column_allowed("orders", "id"), Some(true));
237        assert_eq!(cfg.column_allowed("ORDERS", "TOTAL"), Some(true));
238        assert_eq!(cfg.column_allowed("orders", "email"), Some(false));
239        assert!(cfg.column_allowed("other_table", "id").is_none());
240    }
241
242    #[test]
243    fn wildcard_column_allows_everything_on_that_table() {
244        let mut map = HashMap::new();
245        map.insert("orders".to_string(), vec!["*".to_string()]);
246        let cfg = SqlGuardConfig {
247            column_allowlist: Some(map),
248            ..Default::default()
249        };
250        assert_eq!(cfg.column_allowed("orders", "anything"), Some(true));
251    }
252
253    #[test]
254    fn table_has_column_allowlist_checks_keys() {
255        let mut map = HashMap::new();
256        map.insert("Orders".to_string(), vec!["id".into()]);
257        let cfg = SqlGuardConfig {
258            column_allowlist: Some(map),
259            ..Default::default()
260        };
261        assert!(cfg.table_has_column_allowlist("orders"));
262        assert!(!cfg.table_has_column_allowlist("users"));
263    }
264}