1use std::collections::{HashMap, HashSet};
6
7#[derive(Debug, Clone)]
9pub struct AccessRule {
10 pub table: String,
12 pub row_filter: Option<String>,
14 pub allowed_columns: Option<HashSet<String>>,
16 pub denied_columns: HashSet<String>,
18}
19
20#[derive(Debug, Clone, Default)]
22pub struct AccessContext {
23 pub tenant_id: Option<String>,
25 pub user_id: Option<String>,
27 pub roles: Vec<String>,
29 rules: HashMap<String, AccessRule>,
31}
32
33impl AccessContext {
34 pub fn new() -> Self {
36 Self::default()
37 }
38
39 pub fn with_tenant(mut self, tenant_id: impl Into<String>) -> Self {
41 self.tenant_id = Some(tenant_id.into());
42 self
43 }
44
45 pub fn with_user(mut self, user_id: impl Into<String>) -> Self {
47 self.user_id = Some(user_id.into());
48 self
49 }
50
51 pub fn add_rule(&mut self, rule: AccessRule) {
53 self.rules.insert(rule.table.clone(), rule);
54 }
55
56 pub fn row_filter(&self, table: &str) -> Option<&str> {
58 self.rules.get(table).and_then(|r| r.row_filter.as_deref())
59 }
60
61 pub fn is_column_allowed(&self, table: &str, column: &str) -> bool {
63 if let Some(rule) = self.rules.get(table) {
64 if rule.denied_columns.contains(column) {
65 return false;
66 }
67 if let Some(ref allowed) = rule.allowed_columns {
68 return allowed.contains(column);
69 }
70 }
71 true
72 }
73
74 pub fn filter_columns(&self, table: &str, columns: &[String]) -> Vec<String> {
76 columns
77 .iter()
78 .filter(|col| self.is_column_allowed(table, col))
79 .cloned()
80 .collect()
81 }
82}
83
84pub struct RowLevelSecurity {
86 context: AccessContext,
87}
88
89impl RowLevelSecurity {
90 pub fn new(context: AccessContext) -> Self {
91 Self { context }
92 }
93
94 pub fn tenant_isolation(mut self, table: &str, tenant_column: &str) -> Self {
101 if let Some(ref tenant_id) = self.context.tenant_id {
102 if crate::sql_safety::validate_identifier(table, "table").is_err() {
104 return self;
105 }
106 if crate::sql_safety::validate_identifier(tenant_column, "tenant_column").is_err() {
108 return self;
109 }
110 let escaped_id = escape_sql_literal(tenant_id);
111 self.context.add_rule(AccessRule {
112 table: table.to_string(),
113 row_filter: Some(format!("{} = '{}'", tenant_column, escaped_id)),
114 allowed_columns: None,
115 denied_columns: HashSet::new(),
116 });
117 }
118 self
119 }
120
121 pub fn user_isolation(mut self, table: &str, user_column: &str) -> Self {
128 if let Some(ref user_id) = self.context.user_id {
129 if crate::sql_safety::validate_identifier(table, "table").is_err() {
131 return self;
132 }
133 if crate::sql_safety::validate_identifier(user_column, "user_column").is_err() {
135 return self;
136 }
137 let escaped_id = escape_sql_literal(user_id);
138 self.context.add_rule(AccessRule {
139 table: table.to_string(),
140 row_filter: Some(format!("{} = '{}'", user_column, escaped_id)),
141 allowed_columns: None,
142 denied_columns: HashSet::new(),
143 });
144 }
145 self
146 }
147
148 pub fn deny_columns(mut self, table: &str, columns: &[&str]) -> Self {
150 let rule = self
151 .context
152 .rules
153 .entry(table.to_string())
154 .or_insert(AccessRule {
155 table: table.to_string(),
156 row_filter: None,
157 allowed_columns: None,
158 denied_columns: HashSet::new(),
159 });
160 for col in columns {
161 rule.denied_columns.insert(col.to_string());
162 }
163 self
164 }
165
166 pub fn build(self) -> AccessContext {
167 self.context
168 }
169}
170
171fn escape_sql_literal(s: &str) -> String {
186 let mut out = String::with_capacity(s.len() + 8);
187 for ch in s.chars() {
188 match ch {
189 '\'' => out.push_str("''"),
190 '\\' => out.push_str("\\\\"),
191 '\0' => out.push_str("\\0"),
192 '\n' => out.push_str("\\n"),
193 '\r' => out.push_str("\\r"),
194 '\x1a' => out.push_str("\\Z"),
195 other => out.push(other),
196 }
197 }
198 out
199}
200
201#[cfg(test)]
202mod tests {
203 use super::*;
204
205 #[test]
206 fn test_access_context_default() {
207 let ctx = AccessContext::new();
208 assert!(ctx.tenant_id.is_none());
209 assert!(ctx.user_id.is_none());
210 assert!(ctx.roles.is_empty());
211 }
212
213 #[test]
214 fn test_with_tenant_and_user() {
215 let ctx = AccessContext::new()
216 .with_tenant("tenant-1")
217 .with_user("user-1");
218 assert_eq!(ctx.tenant_id.as_deref(), Some("tenant-1"));
219 assert_eq!(ctx.user_id.as_deref(), Some("user-1"));
220 }
221
222 #[test]
223 fn test_column_allowed_by_default() {
224 let ctx = AccessContext::new();
225 assert!(ctx.is_column_allowed("users", "id"));
226 assert!(ctx.is_column_allowed("users", "password"));
227 }
228
229 #[test]
230 fn test_deny_columns() {
231 let mut ctx = AccessContext::new();
232 ctx.add_rule(AccessRule {
233 table: "users".to_string(),
234 row_filter: None,
235 allowed_columns: None,
236 denied_columns: ["password".to_string()].into_iter().collect(),
237 });
238 assert!(!ctx.is_column_allowed("users", "password"));
239 assert!(ctx.is_column_allowed("users", "id"));
240 }
241
242 #[test]
243 fn test_allowed_columns_whitelist() {
244 let mut ctx = AccessContext::new();
245 let mut allowed: HashSet<String> = HashSet::new();
246 allowed.insert("id".to_string());
247 allowed.insert("name".to_string());
248 ctx.add_rule(AccessRule {
249 table: "users".to_string(),
250 row_filter: None,
251 allowed_columns: Some(allowed),
252 denied_columns: HashSet::new(),
253 });
254 assert!(ctx.is_column_allowed("users", "id"));
255 assert!(ctx.is_column_allowed("users", "name"));
256 assert!(!ctx.is_column_allowed("users", "secret"));
257 }
258
259 #[test]
260 fn test_filter_columns() {
261 let mut ctx = AccessContext::new();
262 ctx.add_rule(AccessRule {
263 table: "users".to_string(),
264 row_filter: None,
265 allowed_columns: None,
266 denied_columns: ["password".to_string()].into_iter().collect(),
267 });
268 let cols = vec!["id".to_string(), "name".to_string(), "password".to_string()];
269 let filtered = ctx.filter_columns("users", &cols);
270 assert_eq!(filtered, vec!["id".to_string(), "name".to_string()]);
271 }
272
273 #[test]
274 fn test_row_filter() {
275 let mut ctx = AccessContext::new();
276 ctx.add_rule(AccessRule {
277 table: "orders".to_string(),
278 row_filter: Some("tenant_id = 't1'".to_string()),
279 allowed_columns: None,
280 denied_columns: HashSet::new(),
281 });
282 assert_eq!(ctx.row_filter("orders"), Some("tenant_id = 't1'"));
283 assert_eq!(ctx.row_filter("users"), None);
284 }
285
286 #[test]
287 fn test_row_level_security_tenant_isolation() {
288 let ctx = AccessContext::new().with_tenant("tenant-42");
289 let built = RowLevelSecurity::new(ctx)
290 .tenant_isolation("orders", "tenant_id")
291 .build();
292 assert_eq!(built.row_filter("orders"), Some("tenant_id = 'tenant-42'"));
293 }
294
295 #[test]
296 fn test_row_level_security_user_isolation() {
297 let ctx = AccessContext::new().with_user("u-1");
298 let built = RowLevelSecurity::new(ctx)
299 .user_isolation("profiles", "user_id")
300 .build();
301 assert_eq!(built.row_filter("profiles"), Some("user_id = 'u-1'"));
302 }
303
304 #[test]
305 fn test_row_level_security_deny_columns() {
306 let ctx = AccessContext::new();
307 let built = RowLevelSecurity::new(ctx)
308 .deny_columns("users", &["password", "salt"])
309 .build();
310 assert!(!built.is_column_allowed("users", "password"));
311 assert!(!built.is_column_allowed("users", "salt"));
312 assert!(built.is_column_allowed("users", "id"));
313 }
314
315 #[test]
316 fn test_tenant_isolation_skipped_without_tenant() {
317 let ctx = AccessContext::new();
319 let built = RowLevelSecurity::new(ctx)
320 .tenant_isolation("orders", "tenant_id")
321 .build();
322 assert_eq!(built.row_filter("orders"), None);
323 }
324
325 #[test]
328 fn test_escape_sql_literal_single_quote() {
329 assert_eq!(escape_sql_literal("O'Brien"), "O''Brien");
331 }
332
333 #[test]
334 fn test_escape_sql_literal_backslash() {
335 assert_eq!(escape_sql_literal(r"a\b"), r"a\\b");
337 assert_eq!(escape_sql_literal(r"\"), r"\\");
338 }
339
340 #[test]
341 fn test_escape_sql_literal_classic_injection() {
342 let escaped = escape_sql_literal("' OR '1'='1");
344 let quote_count = escaped.matches('\'').count();
346 assert_eq!(quote_count % 2, 0, "escaped quotes must be paired");
347 assert_eq!(escaped, "'' OR ''1''=''1");
348 }
349
350 #[test]
351 fn test_escape_sql_literal_mysql_backslash_injection() {
352 let payload = r"\' OR 1=1--";
355 let escaped = escape_sql_literal(payload);
356 assert_eq!(escaped, r"\\'' OR 1=1--");
358 let quote_count = escaped.matches('\'').count();
359 assert_eq!(quote_count % 2, 0, "escaped quotes must be paired");
360 }
361
362 #[test]
363 fn test_escape_sql_literal_null_byte() {
364 assert_eq!(escape_sql_literal("a\0b"), "a\\0b");
366 }
367
368 #[test]
369 fn test_escape_sql_literal_newline_carriage_return() {
370 assert_eq!(escape_sql_literal("a\nb\rc"), r"a\nb\rc");
372 }
373
374 #[test]
375 fn test_escape_sql_literal_ctrl_z() {
376 assert_eq!(escape_sql_literal("a\x1ab"), "a\\Zb");
378 }
379
380 #[test]
381 fn test_tenant_isolation_rejects_invalid_table_name() {
382 let ctx = AccessContext::new().with_tenant("t1");
384 let built = RowLevelSecurity::new(ctx)
385 .tenant_isolation("orders; DROP TABLE users", "tenant_id")
386 .build();
387 assert_eq!(built.row_filter("orders; DROP TABLE users"), None);
388 }
389
390 #[test]
391 fn test_tenant_isolation_rejects_invalid_column_name() {
392 let ctx = AccessContext::new().with_tenant("t1");
394 let built = RowLevelSecurity::new(ctx)
395 .tenant_isolation("orders", "tenant_id; DROP TABLE users")
396 .build();
397 assert_eq!(built.row_filter("orders"), None);
398 }
399
400 #[test]
401 fn test_tenant_isolation_escapes_tenant_id_injection() {
402 let ctx = AccessContext::new().with_tenant("' OR '1'='1");
404 let built = RowLevelSecurity::new(ctx)
405 .tenant_isolation("orders", "tenant_id")
406 .build();
407 let filter = built.row_filter("orders").unwrap();
408 let quote_count = filter.matches('\'').count();
410 assert_eq!(
411 quote_count % 2,
412 0,
413 "tenant_id injection not escaped: {filter}"
414 );
415 assert_eq!(filter, "tenant_id = ''' OR ''1''=''1'");
416 }
417
418 #[test]
419 fn test_user_isolation_escapes_user_id_backslash_injection() {
420 let ctx = AccessContext::new().with_user(r"\' OR 1=1--");
422 let built = RowLevelSecurity::new(ctx)
423 .user_isolation("profiles", "user_id")
424 .build();
425 let filter = built.row_filter("profiles").unwrap();
426 let quote_count = filter.matches('\'').count();
427 assert_eq!(
428 quote_count % 2,
429 0,
430 "user_id backslash injection not escaped: {filter}"
431 );
432 }
433}