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 {
92 Self { context }
93 }
94
95 pub fn tenant_isolation(mut self, table: &str, tenant_column: &str) -> Self {
102 if let Some(ref tenant_id) = self.context.tenant_id {
103 if crate::sql_safety::validate_identifier(table, "table").is_err() {
105 return self;
106 }
107 if crate::sql_safety::validate_identifier(tenant_column, "tenant_column").is_err() {
109 return self;
110 }
111 let escaped_id = escape_sql_literal(tenant_id);
112 self.context.add_rule(AccessRule {
113 table: table.to_string(),
114 row_filter: Some(format!("{} = '{}'", tenant_column, escaped_id)),
115 allowed_columns: None,
116 denied_columns: HashSet::new(),
117 });
118 }
119 self
120 }
121
122 pub fn user_isolation(mut self, table: &str, user_column: &str) -> Self {
129 if let Some(ref user_id) = self.context.user_id {
130 if crate::sql_safety::validate_identifier(table, "table").is_err() {
132 return self;
133 }
134 if crate::sql_safety::validate_identifier(user_column, "user_column").is_err() {
136 return self;
137 }
138 let escaped_id = escape_sql_literal(user_id);
139 self.context.add_rule(AccessRule {
140 table: table.to_string(),
141 row_filter: Some(format!("{} = '{}'", user_column, escaped_id)),
142 allowed_columns: None,
143 denied_columns: HashSet::new(),
144 });
145 }
146 self
147 }
148
149 pub fn deny_columns(mut self, table: &str, columns: &[&str]) -> Self {
151 let rule = self
152 .context
153 .rules
154 .entry(table.to_string())
155 .or_insert(AccessRule {
156 table: table.to_string(),
157 row_filter: None,
158 allowed_columns: None,
159 denied_columns: HashSet::new(),
160 });
161 for col in columns {
162 rule.denied_columns.insert(col.to_string());
163 }
164 self
165 }
166
167 pub fn build(self) -> AccessContext {
169 self.context
170 }
171}
172
173fn escape_sql_literal(s: &str) -> String {
188 let mut out = String::with_capacity(s.len() + 8);
189 for ch in s.chars() {
190 match ch {
191 '\'' => out.push_str("''"),
192 '\\' => out.push_str("\\\\"),
193 '\0' => out.push_str("\\0"),
194 '\n' => out.push_str("\\n"),
195 '\r' => out.push_str("\\r"),
196 '\x1a' => out.push_str("\\Z"),
197 other => out.push(other),
198 }
199 }
200 out
201}
202
203#[cfg(feature = "multi-tenant-enhanced")]
206impl AccessContext {
207 pub fn apply_row_level_security(
212 ctx: &crate::tenant_context::TenantContext,
213 table: &str,
214 ) -> Option<crate::tenant_security::ParameterizedCondition> {
215 ctx.permissions
216 .row_level_policies
217 .iter()
218 .find(|p| p.table == table && p.principal.tenant_id == ctx.tenant_id)
219 .map(|p| p.filter_condition.clone())
220 }
221
222 pub fn apply_column_masking(
227 ctx: &crate::tenant_context::TenantContext,
228 table: &str,
229 column: &str,
230 ) -> Option<crate::tenant_security::ColumnMaskingRule> {
231 ctx.permissions
232 .column_masking_rules
233 .iter()
234 .find(|r| {
235 r.table == table && r.column == column && r.applies_to(&ctx.permissions.roles)
236 })
237 .cloned()
238 }
239}
240
241#[cfg(test)]
242mod tests {
243 use super::*;
244
245 #[test]
246 fn test_access_context_default() {
247 let ctx = AccessContext::new();
248 assert!(ctx.tenant_id.is_none());
249 assert!(ctx.user_id.is_none());
250 assert!(ctx.roles.is_empty());
251 }
252
253 #[test]
254 fn test_with_tenant_and_user() {
255 let ctx = AccessContext::new()
256 .with_tenant("tenant-1")
257 .with_user("user-1");
258 assert_eq!(ctx.tenant_id.as_deref(), Some("tenant-1"));
259 assert_eq!(ctx.user_id.as_deref(), Some("user-1"));
260 }
261
262 #[test]
263 fn test_column_allowed_by_default() {
264 let ctx = AccessContext::new();
265 assert!(ctx.is_column_allowed("users", "id"));
266 assert!(ctx.is_column_allowed("users", "password"));
267 }
268
269 #[test]
270 fn test_deny_columns() {
271 let mut ctx = AccessContext::new();
272 ctx.add_rule(AccessRule {
273 table: "users".to_string(),
274 row_filter: None,
275 allowed_columns: None,
276 denied_columns: ["password".to_string()].into_iter().collect(),
277 });
278 assert!(!ctx.is_column_allowed("users", "password"));
279 assert!(ctx.is_column_allowed("users", "id"));
280 }
281
282 #[test]
283 fn test_allowed_columns_whitelist() {
284 let mut ctx = AccessContext::new();
285 let mut allowed: HashSet<String> = HashSet::new();
286 allowed.insert("id".to_string());
287 allowed.insert("name".to_string());
288 ctx.add_rule(AccessRule {
289 table: "users".to_string(),
290 row_filter: None,
291 allowed_columns: Some(allowed),
292 denied_columns: HashSet::new(),
293 });
294 assert!(ctx.is_column_allowed("users", "id"));
295 assert!(ctx.is_column_allowed("users", "name"));
296 assert!(!ctx.is_column_allowed("users", "secret"));
297 }
298
299 #[test]
300 fn test_filter_columns() {
301 let mut ctx = AccessContext::new();
302 ctx.add_rule(AccessRule {
303 table: "users".to_string(),
304 row_filter: None,
305 allowed_columns: None,
306 denied_columns: ["password".to_string()].into_iter().collect(),
307 });
308 let cols = vec!["id".to_string(), "name".to_string(), "password".to_string()];
309 let filtered = ctx.filter_columns("users", &cols);
310 assert_eq!(filtered, vec!["id".to_string(), "name".to_string()]);
311 }
312
313 #[test]
314 fn test_row_filter() {
315 let mut ctx = AccessContext::new();
316 ctx.add_rule(AccessRule {
317 table: "orders".to_string(),
318 row_filter: Some("tenant_id = 't1'".to_string()),
319 allowed_columns: None,
320 denied_columns: HashSet::new(),
321 });
322 assert_eq!(ctx.row_filter("orders"), Some("tenant_id = 't1'"));
323 assert_eq!(ctx.row_filter("users"), None);
324 }
325
326 #[test]
327 fn test_row_level_security_tenant_isolation() {
328 let ctx = AccessContext::new().with_tenant("tenant-42");
329 let built = RowLevelSecurity::new(ctx)
330 .tenant_isolation("orders", "tenant_id")
331 .build();
332 assert_eq!(built.row_filter("orders"), Some("tenant_id = 'tenant-42'"));
333 }
334
335 #[test]
336 fn test_row_level_security_user_isolation() {
337 let ctx = AccessContext::new().with_user("u-1");
338 let built = RowLevelSecurity::new(ctx)
339 .user_isolation("profiles", "user_id")
340 .build();
341 assert_eq!(built.row_filter("profiles"), Some("user_id = 'u-1'"));
342 }
343
344 #[test]
345 fn test_row_level_security_deny_columns() {
346 let ctx = AccessContext::new();
347 let built = RowLevelSecurity::new(ctx)
348 .deny_columns("users", &["password", "salt"])
349 .build();
350 assert!(!built.is_column_allowed("users", "password"));
351 assert!(!built.is_column_allowed("users", "salt"));
352 assert!(built.is_column_allowed("users", "id"));
353 }
354
355 #[test]
356 fn test_tenant_isolation_skipped_without_tenant() {
357 let ctx = AccessContext::new();
359 let built = RowLevelSecurity::new(ctx)
360 .tenant_isolation("orders", "tenant_id")
361 .build();
362 assert_eq!(built.row_filter("orders"), None);
363 }
364
365 #[test]
368 fn test_escape_sql_literal_single_quote() {
369 assert_eq!(escape_sql_literal("O'Brien"), "O''Brien");
371 }
372
373 #[test]
374 fn test_escape_sql_literal_backslash() {
375 assert_eq!(escape_sql_literal(r"a\b"), r"a\\b");
377 assert_eq!(escape_sql_literal(r"\"), r"\\");
378 }
379
380 #[test]
381 fn test_escape_sql_literal_classic_injection() {
382 let escaped = escape_sql_literal("' OR '1'='1");
384 let quote_count = escaped.matches('\'').count();
386 assert_eq!(quote_count % 2, 0, "escaped quotes must be paired");
387 assert_eq!(escaped, "'' OR ''1''=''1");
388 }
389
390 #[test]
391 fn test_escape_sql_literal_mysql_backslash_injection() {
392 let payload = r"\' OR 1=1--";
395 let escaped = escape_sql_literal(payload);
396 assert_eq!(escaped, r"\\'' OR 1=1--");
398 let quote_count = escaped.matches('\'').count();
399 assert_eq!(quote_count % 2, 0, "escaped quotes must be paired");
400 }
401
402 #[test]
403 fn test_escape_sql_literal_null_byte() {
404 assert_eq!(escape_sql_literal("a\0b"), "a\\0b");
406 }
407
408 #[test]
409 fn test_escape_sql_literal_newline_carriage_return() {
410 assert_eq!(escape_sql_literal("a\nb\rc"), r"a\nb\rc");
412 }
413
414 #[test]
415 fn test_escape_sql_literal_ctrl_z() {
416 assert_eq!(escape_sql_literal("a\x1ab"), "a\\Zb");
418 }
419
420 #[test]
421 fn test_tenant_isolation_rejects_invalid_table_name() {
422 let ctx = AccessContext::new().with_tenant("t1");
424 let built = RowLevelSecurity::new(ctx)
425 .tenant_isolation("orders; DROP TABLE users", "tenant_id")
426 .build();
427 assert_eq!(built.row_filter("orders; DROP TABLE users"), None);
428 }
429
430 #[test]
431 fn test_tenant_isolation_rejects_invalid_column_name() {
432 let ctx = AccessContext::new().with_tenant("t1");
434 let built = RowLevelSecurity::new(ctx)
435 .tenant_isolation("orders", "tenant_id; DROP TABLE users")
436 .build();
437 assert_eq!(built.row_filter("orders"), None);
438 }
439
440 #[test]
441 fn test_tenant_isolation_escapes_tenant_id_injection() {
442 let ctx = AccessContext::new().with_tenant("' OR '1'='1");
444 let built = RowLevelSecurity::new(ctx)
445 .tenant_isolation("orders", "tenant_id")
446 .build();
447 let filter = built.row_filter("orders").unwrap();
448 let quote_count = filter.matches('\'').count();
450 assert_eq!(
451 quote_count % 2,
452 0,
453 "tenant_id injection not escaped: {filter}"
454 );
455 assert_eq!(filter, "tenant_id = ''' OR ''1''=''1'");
456 }
457
458 #[test]
459 fn test_user_isolation_escapes_user_id_backslash_injection() {
460 let ctx = AccessContext::new().with_user(r"\' OR 1=1--");
462 let built = RowLevelSecurity::new(ctx)
463 .user_isolation("profiles", "user_id")
464 .build();
465 let filter = built.row_filter("profiles").unwrap();
466 let quote_count = filter.matches('\'').count();
467 assert_eq!(
468 quote_count % 2,
469 0,
470 "user_id backslash injection not escaped: {filter}"
471 );
472 }
473}