Skip to main content

sz_orm_core/
access_control.rs

1//! 行级和字段级权限控制
2//!
3//! 提供基于租户/用户的行级数据隔离和字段级访问控制
4
5use std::collections::{HashMap, HashSet};
6
7/// 权限规则
8#[derive(Debug, Clone)]
9pub struct AccessRule {
10    /// 表名
11    pub table: String,
12    /// 行级过滤条件(SQL WHERE 子句片段)
13    pub row_filter: Option<String>,
14    /// 允许查询的字段列表(None 表示允许所有字段)
15    pub allowed_columns: Option<HashSet<String>>,
16    /// 禁止查询的字段列表
17    pub denied_columns: HashSet<String>,
18}
19
20/// 访问控制上下文
21#[derive(Debug, Clone, Default)]
22pub struct AccessContext {
23    /// 当前租户 ID
24    pub tenant_id: Option<String>,
25    /// 当前用户 ID
26    pub user_id: Option<String>,
27    /// 角色列表
28    pub roles: Vec<String>,
29    /// 表级权限规则
30    rules: HashMap<String, AccessRule>,
31}
32
33impl AccessContext {
34    /// 创建新的访问控制上下文
35    pub fn new() -> Self {
36        Self::default()
37    }
38
39    /// 设置租户 ID
40    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    /// 设置用户 ID
46    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    /// 添加访问规则
52    pub fn add_rule(&mut self, rule: AccessRule) {
53        self.rules.insert(rule.table.clone(), rule);
54    }
55
56    /// 获取表的行级过滤条件
57    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    /// 检查字段是否允许查询
62    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    /// 过滤字段列表,返回允许查询的字段
75    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
84/// 行级权限构建器
85pub struct RowLevelSecurity {
86    context: AccessContext,
87}
88
89impl RowLevelSecurity {
90    pub fn new(context: AccessContext) -> Self {
91        Self { context }
92    }
93
94    /// 为表添加租户隔离规则
95    ///
96    /// # 安全
97    /// - `table` 会校验为合法 SQL 标识符(防注入)
98    /// - `tenant_column` 会校验为合法 SQL 标识符(防注入)
99    /// - `tenant_id` 会转义单引号与反斜杠(防注入)
100    pub fn tenant_isolation(mut self, table: &str, tenant_column: &str) -> Self {
101        if let Some(ref tenant_id) = self.context.tenant_id {
102            // 校验表名为合法标识符
103            if crate::sql_safety::validate_identifier(table, "table").is_err() {
104                return self;
105            }
106            // 校验列名为合法标识符
107            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    /// 为表添加用户隔离规则
122    ///
123    /// # 安全
124    /// - `table` 会校验为合法 SQL 标识符(防注入)
125    /// - `user_column` 会校验为合法 SQL 标识符(防注入)
126    /// - `user_id` 会转义单引号与反斜杠(防注入)
127    pub fn user_isolation(mut self, table: &str, user_column: &str) -> Self {
128        if let Some(ref user_id) = self.context.user_id {
129            // 校验表名为合法标识符
130            if crate::sql_safety::validate_identifier(table, "table").is_err() {
131                return self;
132            }
133            // 校验列名为合法标识符
134            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    /// 禁止查询敏感字段
149    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
171/// 转义 SQL 字面量字符串
172///
173/// # 处理规则
174///
175/// 1. 单引号 `'` → `''`(SQL 标准)
176/// 2. 反斜杠 `\` → `\\`(MySQL 默认模式 `NO_BACKSLASH_ESCAPES` 未启用时为转义字符)
177/// 3. NULL 字节 `\0` → `\0`(MySQL 会截断字符串)
178/// 4. 换行 `\n` / 回车 `\r` → `\\n` / `\\r`(防止日志注入)
179/// 5. Ctrl+Z `\x1a` → `\\Z`(Windows MySQL 截断字符)
180///
181/// # 注意
182///
183/// 此函数仅用于无法使用参数化查询的边角场景(如动态 WHERE 拼接)。
184/// **首选方案永远是参数化查询**(`?` 占位符 + Value 绑定)。
185fn 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        // 未设置 tenant_id 时不应添加规则
318        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    // ===== SQL 注入防护测试 =====
326
327    #[test]
328    fn test_escape_sql_literal_single_quote() {
329        // 单引号 → '' (SQL 标准)
330        assert_eq!(escape_sql_literal("O'Brien"), "O''Brien");
331    }
332
333    #[test]
334    fn test_escape_sql_literal_backslash() {
335        // 反斜杠 → \\(MySQL 默认模式防注入)
336        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        // 经典注入 payload:' OR '1'='1
343        let escaped = escape_sql_literal("' OR '1'='1");
344        // 单引号成对出现,不会破坏外层字面量
345        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        // MySQL 注入 payload:\'
353        // 攻击者用反斜杠让单引号转义失效,escape 后应同时处理 \ 和 '
354        let payload = r"\' OR 1=1--";
355        let escaped = escape_sql_literal(payload);
356        // \ → \\,' → '',结果不应包含未配对单引号
357        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        // NULL 字节会被 MySQL 截断字符串
365        assert_eq!(escape_sql_literal("a\0b"), "a\\0b");
366    }
367
368    #[test]
369    fn test_escape_sql_literal_newline_carriage_return() {
370        // 换行/回车防止日志注入
371        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        // Windows MySQL Ctrl+Z 截断:\x1a → \Z(反斜杠 + Z,共 2 字符)
377        assert_eq!(escape_sql_literal("a\x1ab"), "a\\Zb");
378    }
379
380    #[test]
381    fn test_tenant_isolation_rejects_invalid_table_name() {
382        // 表名为非法标识符时不应添加规则
383        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        // 列名为非法标识符时不应添加规则
393        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        // tenant_id 含注入 payload,应被正确转义
403        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        // 单引号应被转义为成对出现
409        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        // user_id 含反斜杠注入 payload
421        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}