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    /// 创建行级权限控制器
91    pub fn new(context: AccessContext) -> Self {
92        Self { context }
93    }
94
95    /// 为表添加租户隔离规则
96    ///
97    /// # 安全
98    /// - `table` 会校验为合法 SQL 标识符(防注入)
99    /// - `tenant_column` 会校验为合法 SQL 标识符(防注入)
100    /// - `tenant_id` 会转义单引号与反斜杠(防注入)
101    pub fn tenant_isolation(mut self, table: &str, tenant_column: &str) -> Self {
102        if let Some(ref tenant_id) = self.context.tenant_id {
103            // 校验表名为合法标识符
104            if crate::sql_safety::validate_identifier(table, "table").is_err() {
105                return self;
106            }
107            // 校验列名为合法标识符
108            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    /// 为表添加用户隔离规则
123    ///
124    /// # 安全
125    /// - `table` 会校验为合法 SQL 标识符(防注入)
126    /// - `user_column` 会校验为合法 SQL 标识符(防注入)
127    /// - `user_id` 会转义单引号与反斜杠(防注入)
128    pub fn user_isolation(mut self, table: &str, user_column: &str) -> Self {
129        if let Some(ref user_id) = self.context.user_id {
130            // 校验表名为合法标识符
131            if crate::sql_safety::validate_identifier(table, "table").is_err() {
132                return self;
133            }
134            // 校验列名为合法标识符
135            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    /// 禁止查询敏感字段
150    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    /// 构建并返回最终的访问控制上下文
168    pub fn build(self) -> AccessContext {
169        self.context
170    }
171}
172
173/// 转义 SQL 字面量字符串
174///
175/// # 处理规则
176///
177/// 1. 单引号 `'` → `''`(SQL 标准)
178/// 2. 反斜杠 `\` → `\\`(MySQL 默认模式 `NO_BACKSLASH_ESCAPES` 未启用时为转义字符)
179/// 3. NULL 字节 `\0` → `\0`(MySQL 会截断字符串)
180/// 4. 换行 `\n` / 回车 `\r` → `\\n` / `\\r`(防止日志注入)
181/// 5. Ctrl+Z `\x1a` → `\\Z`(Windows MySQL 截断字符)
182///
183/// # 注意
184///
185/// 此函数仅用于无法使用参数化查询的边角场景(如动态 WHERE 拼接)。
186/// **首选方案永远是参数化查询**(`?` 占位符 + Value 绑定)。
187fn 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// ─── v3.3.0 multi-tenant-enhanced:行级安全策略集成 ──────────────────
204
205#[cfg(feature = "multi-tenant-enhanced")]
206impl AccessContext {
207    /// 应用行级安全策略,返回参数化过滤条件
208    ///
209    /// 从 `TenantContext` 的权限中查找匹配表名的行级安全策略,
210    /// 返回其参数化过滤条件。既有 `AccessRule` 不变。
211    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    /// 应用列级脱敏规则,返回匹配的脱敏规则列表
223    ///
224    /// 从 `TenantContext` 的权限中查找匹配表名 + 列名的脱敏规则,
225    /// 且规则适用于当前角色列表。
226    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        // 未设置 tenant_id 时不应添加规则
358        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    // ===== SQL 注入防护测试 =====
366
367    #[test]
368    fn test_escape_sql_literal_single_quote() {
369        // 单引号 → '' (SQL 标准)
370        assert_eq!(escape_sql_literal("O'Brien"), "O''Brien");
371    }
372
373    #[test]
374    fn test_escape_sql_literal_backslash() {
375        // 反斜杠 → \\(MySQL 默认模式防注入)
376        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        // 经典注入 payload:' OR '1'='1
383        let escaped = escape_sql_literal("' OR '1'='1");
384        // 单引号成对出现,不会破坏外层字面量
385        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        // MySQL 注入 payload:\'
393        // 攻击者用反斜杠让单引号转义失效,escape 后应同时处理 \ 和 '
394        let payload = r"\' OR 1=1--";
395        let escaped = escape_sql_literal(payload);
396        // \ → \\,' → '',结果不应包含未配对单引号
397        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        // NULL 字节会被 MySQL 截断字符串
405        assert_eq!(escape_sql_literal("a\0b"), "a\\0b");
406    }
407
408    #[test]
409    fn test_escape_sql_literal_newline_carriage_return() {
410        // 换行/回车防止日志注入
411        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        // Windows MySQL Ctrl+Z 截断:\x1a → \Z(反斜杠 + Z,共 2 字符)
417        assert_eq!(escape_sql_literal("a\x1ab"), "a\\Zb");
418    }
419
420    #[test]
421    fn test_tenant_isolation_rejects_invalid_table_name() {
422        // 表名为非法标识符时不应添加规则
423        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        // 列名为非法标识符时不应添加规则
433        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        // tenant_id 含注入 payload,应被正确转义
443        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        // 单引号应被转义为成对出现
449        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        // user_id 含反斜杠注入 payload
461        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}