use std::collections::{HashMap, HashSet};
#[derive(Debug, Clone)]
pub struct AccessRule {
pub table: String,
pub row_filter: Option<String>,
pub allowed_columns: Option<HashSet<String>>,
pub denied_columns: HashSet<String>,
}
#[derive(Debug, Clone, Default)]
pub struct AccessContext {
pub tenant_id: Option<String>,
pub user_id: Option<String>,
pub roles: Vec<String>,
rules: HashMap<String, AccessRule>,
}
impl AccessContext {
pub fn new() -> Self {
Self::default()
}
pub fn with_tenant(mut self, tenant_id: impl Into<String>) -> Self {
self.tenant_id = Some(tenant_id.into());
self
}
pub fn with_user(mut self, user_id: impl Into<String>) -> Self {
self.user_id = Some(user_id.into());
self
}
pub fn add_rule(&mut self, rule: AccessRule) {
self.rules.insert(rule.table.clone(), rule);
}
pub fn row_filter(&self, table: &str) -> Option<&str> {
self.rules.get(table).and_then(|r| r.row_filter.as_deref())
}
pub fn is_column_allowed(&self, table: &str, column: &str) -> bool {
if let Some(rule) = self.rules.get(table) {
if rule.denied_columns.contains(column) {
return false;
}
if let Some(ref allowed) = rule.allowed_columns {
return allowed.contains(column);
}
}
true
}
pub fn filter_columns(&self, table: &str, columns: &[String]) -> Vec<String> {
columns
.iter()
.filter(|col| self.is_column_allowed(table, col))
.cloned()
.collect()
}
}
pub struct RowLevelSecurity {
context: AccessContext,
}
impl RowLevelSecurity {
pub fn new(context: AccessContext) -> Self {
Self { context }
}
pub fn tenant_isolation(mut self, table: &str, tenant_column: &str) -> Self {
if let Some(ref tenant_id) = self.context.tenant_id {
self.context.add_rule(AccessRule {
table: table.to_string(),
row_filter: Some(format!("{} = '{}'", tenant_column, tenant_id)),
allowed_columns: None,
denied_columns: HashSet::new(),
});
}
self
}
pub fn user_isolation(mut self, table: &str, user_column: &str) -> Self {
if let Some(ref user_id) = self.context.user_id {
self.context.add_rule(AccessRule {
table: table.to_string(),
row_filter: Some(format!("{} = '{}'", user_column, user_id)),
allowed_columns: None,
denied_columns: HashSet::new(),
});
}
self
}
pub fn deny_columns(mut self, table: &str, columns: &[&str]) -> Self {
let rule = self
.context
.rules
.entry(table.to_string())
.or_insert(AccessRule {
table: table.to_string(),
row_filter: None,
allowed_columns: None,
denied_columns: HashSet::new(),
});
for col in columns {
rule.denied_columns.insert(col.to_string());
}
self
}
pub fn build(self) -> AccessContext {
self.context
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_access_context_default() {
let ctx = AccessContext::new();
assert!(ctx.tenant_id.is_none());
assert!(ctx.user_id.is_none());
assert!(ctx.roles.is_empty());
}
#[test]
fn test_with_tenant_and_user() {
let ctx = AccessContext::new()
.with_tenant("tenant-1")
.with_user("user-1");
assert_eq!(ctx.tenant_id.as_deref(), Some("tenant-1"));
assert_eq!(ctx.user_id.as_deref(), Some("user-1"));
}
#[test]
fn test_column_allowed_by_default() {
let ctx = AccessContext::new();
assert!(ctx.is_column_allowed("users", "id"));
assert!(ctx.is_column_allowed("users", "password"));
}
#[test]
fn test_deny_columns() {
let mut ctx = AccessContext::new();
ctx.add_rule(AccessRule {
table: "users".to_string(),
row_filter: None,
allowed_columns: None,
denied_columns: ["password".to_string()].into_iter().collect(),
});
assert!(!ctx.is_column_allowed("users", "password"));
assert!(ctx.is_column_allowed("users", "id"));
}
#[test]
fn test_allowed_columns_whitelist() {
let mut ctx = AccessContext::new();
let mut allowed: HashSet<String> = HashSet::new();
allowed.insert("id".to_string());
allowed.insert("name".to_string());
ctx.add_rule(AccessRule {
table: "users".to_string(),
row_filter: None,
allowed_columns: Some(allowed),
denied_columns: HashSet::new(),
});
assert!(ctx.is_column_allowed("users", "id"));
assert!(ctx.is_column_allowed("users", "name"));
assert!(!ctx.is_column_allowed("users", "secret"));
}
#[test]
fn test_filter_columns() {
let mut ctx = AccessContext::new();
ctx.add_rule(AccessRule {
table: "users".to_string(),
row_filter: None,
allowed_columns: None,
denied_columns: ["password".to_string()].into_iter().collect(),
});
let cols = vec!["id".to_string(), "name".to_string(), "password".to_string()];
let filtered = ctx.filter_columns("users", &cols);
assert_eq!(filtered, vec!["id".to_string(), "name".to_string()]);
}
#[test]
fn test_row_filter() {
let mut ctx = AccessContext::new();
ctx.add_rule(AccessRule {
table: "orders".to_string(),
row_filter: Some("tenant_id = 't1'".to_string()),
allowed_columns: None,
denied_columns: HashSet::new(),
});
assert_eq!(ctx.row_filter("orders"), Some("tenant_id = 't1'"));
assert_eq!(ctx.row_filter("users"), None);
}
#[test]
fn test_row_level_security_tenant_isolation() {
let ctx = AccessContext::new().with_tenant("tenant-42");
let built = RowLevelSecurity::new(ctx)
.tenant_isolation("orders", "tenant_id")
.build();
assert_eq!(built.row_filter("orders"), Some("tenant_id = 'tenant-42'"));
}
#[test]
fn test_row_level_security_user_isolation() {
let ctx = AccessContext::new().with_user("u-1");
let built = RowLevelSecurity::new(ctx)
.user_isolation("profiles", "user_id")
.build();
assert_eq!(built.row_filter("profiles"), Some("user_id = 'u-1'"));
}
#[test]
fn test_row_level_security_deny_columns() {
let ctx = AccessContext::new();
let built = RowLevelSecurity::new(ctx)
.deny_columns("users", &["password", "salt"])
.build();
assert!(!built.is_column_allowed("users", "password"));
assert!(!built.is_column_allowed("users", "salt"));
assert!(built.is_column_allowed("users", "id"));
}
#[test]
fn test_tenant_isolation_skipped_without_tenant() {
let ctx = AccessContext::new();
let built = RowLevelSecurity::new(ctx)
.tenant_isolation("orders", "tenant_id")
.build();
assert_eq!(built.row_filter("orders"), None);
}
}