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}