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 {
96 if let Some(ref tenant_id) = self.context.tenant_id {
97 self.context.add_rule(AccessRule {
98 table: table.to_string(),
99 row_filter: Some(format!("{} = '{}'", tenant_column, tenant_id)),
100 allowed_columns: None,
101 denied_columns: HashSet::new(),
102 });
103 }
104 self
105 }
106
107 pub fn user_isolation(mut self, table: &str, user_column: &str) -> Self {
109 if let Some(ref user_id) = self.context.user_id {
110 self.context.add_rule(AccessRule {
111 table: table.to_string(),
112 row_filter: Some(format!("{} = '{}'", user_column, user_id)),
113 allowed_columns: None,
114 denied_columns: HashSet::new(),
115 });
116 }
117 self
118 }
119
120 pub fn deny_columns(mut self, table: &str, columns: &[&str]) -> Self {
122 let rule = self
123 .context
124 .rules
125 .entry(table.to_string())
126 .or_insert(AccessRule {
127 table: table.to_string(),
128 row_filter: None,
129 allowed_columns: None,
130 denied_columns: HashSet::new(),
131 });
132 for col in columns {
133 rule.denied_columns.insert(col.to_string());
134 }
135 self
136 }
137
138 pub fn build(self) -> AccessContext {
139 self.context
140 }
141}
142
143#[cfg(test)]
144mod tests {
145 use super::*;
146
147 #[test]
148 fn test_access_context_default() {
149 let ctx = AccessContext::new();
150 assert!(ctx.tenant_id.is_none());
151 assert!(ctx.user_id.is_none());
152 assert!(ctx.roles.is_empty());
153 }
154
155 #[test]
156 fn test_with_tenant_and_user() {
157 let ctx = AccessContext::new()
158 .with_tenant("tenant-1")
159 .with_user("user-1");
160 assert_eq!(ctx.tenant_id.as_deref(), Some("tenant-1"));
161 assert_eq!(ctx.user_id.as_deref(), Some("user-1"));
162 }
163
164 #[test]
165 fn test_column_allowed_by_default() {
166 let ctx = AccessContext::new();
167 assert!(ctx.is_column_allowed("users", "id"));
168 assert!(ctx.is_column_allowed("users", "password"));
169 }
170
171 #[test]
172 fn test_deny_columns() {
173 let mut ctx = AccessContext::new();
174 ctx.add_rule(AccessRule {
175 table: "users".to_string(),
176 row_filter: None,
177 allowed_columns: None,
178 denied_columns: ["password".to_string()].into_iter().collect(),
179 });
180 assert!(!ctx.is_column_allowed("users", "password"));
181 assert!(ctx.is_column_allowed("users", "id"));
182 }
183
184 #[test]
185 fn test_allowed_columns_whitelist() {
186 let mut ctx = AccessContext::new();
187 let mut allowed: HashSet<String> = HashSet::new();
188 allowed.insert("id".to_string());
189 allowed.insert("name".to_string());
190 ctx.add_rule(AccessRule {
191 table: "users".to_string(),
192 row_filter: None,
193 allowed_columns: Some(allowed),
194 denied_columns: HashSet::new(),
195 });
196 assert!(ctx.is_column_allowed("users", "id"));
197 assert!(ctx.is_column_allowed("users", "name"));
198 assert!(!ctx.is_column_allowed("users", "secret"));
199 }
200
201 #[test]
202 fn test_filter_columns() {
203 let mut ctx = AccessContext::new();
204 ctx.add_rule(AccessRule {
205 table: "users".to_string(),
206 row_filter: None,
207 allowed_columns: None,
208 denied_columns: ["password".to_string()].into_iter().collect(),
209 });
210 let cols = vec!["id".to_string(), "name".to_string(), "password".to_string()];
211 let filtered = ctx.filter_columns("users", &cols);
212 assert_eq!(filtered, vec!["id".to_string(), "name".to_string()]);
213 }
214
215 #[test]
216 fn test_row_filter() {
217 let mut ctx = AccessContext::new();
218 ctx.add_rule(AccessRule {
219 table: "orders".to_string(),
220 row_filter: Some("tenant_id = 't1'".to_string()),
221 allowed_columns: None,
222 denied_columns: HashSet::new(),
223 });
224 assert_eq!(ctx.row_filter("orders"), Some("tenant_id = 't1'"));
225 assert_eq!(ctx.row_filter("users"), None);
226 }
227
228 #[test]
229 fn test_row_level_security_tenant_isolation() {
230 let ctx = AccessContext::new().with_tenant("tenant-42");
231 let built = RowLevelSecurity::new(ctx)
232 .tenant_isolation("orders", "tenant_id")
233 .build();
234 assert_eq!(built.row_filter("orders"), Some("tenant_id = 'tenant-42'"));
235 }
236
237 #[test]
238 fn test_row_level_security_user_isolation() {
239 let ctx = AccessContext::new().with_user("u-1");
240 let built = RowLevelSecurity::new(ctx)
241 .user_isolation("profiles", "user_id")
242 .build();
243 assert_eq!(built.row_filter("profiles"), Some("user_id = 'u-1'"));
244 }
245
246 #[test]
247 fn test_row_level_security_deny_columns() {
248 let ctx = AccessContext::new();
249 let built = RowLevelSecurity::new(ctx)
250 .deny_columns("users", &["password", "salt"])
251 .build();
252 assert!(!built.is_column_allowed("users", "password"));
253 assert!(!built.is_column_allowed("users", "salt"));
254 assert!(built.is_column_allowed("users", "id"));
255 }
256
257 #[test]
258 fn test_tenant_isolation_skipped_without_tenant() {
259 let ctx = AccessContext::new();
261 let built = RowLevelSecurity::new(ctx)
262 .tenant_isolation("orders", "tenant_id")
263 .build();
264 assert_eq!(built.row_filter("orders"), None);
265 }
266}