Skip to main content

mongreldb_core/
security.rs

1//! Persistent row policies and column masking.
2
3use crate::auth::Principal;
4use crate::memtable::{Row, Value};
5use serde::{Deserialize, Serialize};
6use sha2::{Digest, Sha256};
7
8#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
9pub struct SecurityCatalog {
10    #[serde(default)]
11    pub rls_tables: Vec<String>,
12    #[serde(default)]
13    pub policies: Vec<RowPolicy>,
14    #[serde(default)]
15    pub masks: Vec<ColumnMask>,
16}
17
18#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
19pub struct RowPolicy {
20    pub name: String,
21    pub table: String,
22    pub command: PolicyCommand,
23    #[serde(default)]
24    pub subjects: Vec<String>,
25    #[serde(default = "default_true")]
26    pub permissive: bool,
27    pub using: Option<SecurityExpr>,
28    pub with_check: Option<SecurityExpr>,
29}
30
31#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
32#[serde(rename_all = "snake_case")]
33pub enum PolicyCommand {
34    All,
35    Select,
36    Insert,
37    Update,
38    Delete,
39}
40
41#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
42#[serde(tag = "kind", rename_all = "snake_case")]
43pub enum SecurityExpr {
44    True,
45    ColumnEqCurrentUser {
46        column: u16,
47    },
48    ColumnEqValue {
49        column: u16,
50        value: Value,
51    },
52    And {
53        left: Box<SecurityExpr>,
54        right: Box<SecurityExpr>,
55    },
56    Or {
57        left: Box<SecurityExpr>,
58        right: Box<SecurityExpr>,
59    },
60    Not {
61        expression: Box<SecurityExpr>,
62    },
63}
64
65#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
66pub struct ColumnMask {
67    pub name: String,
68    pub table: String,
69    pub column: u16,
70    pub strategy: MaskStrategy,
71    #[serde(default)]
72    pub exempt_subjects: Vec<String>,
73}
74
75#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
76#[serde(tag = "kind", rename_all = "snake_case")]
77pub enum MaskStrategy {
78    Null,
79    Redact { replacement: String },
80    Sha256,
81}
82
83impl SecurityCatalog {
84    pub fn table_has_security(&self, table: &str) -> bool {
85        self.rls_tables.iter().any(|name| name == table)
86            || self.masks.iter().any(|mask| mask.table == table)
87    }
88
89    pub fn table_has_objects(&self, table: &str) -> bool {
90        self.table_has_security(table) || self.policies.iter().any(|policy| policy.table == table)
91    }
92
93    pub fn rls_enabled(&self, table: &str) -> bool {
94        self.rls_tables.iter().any(|name| name == table)
95    }
96
97    pub fn row_allowed(
98        &self,
99        table: &str,
100        command: PolicyCommand,
101        row: &Row,
102        principal: &Principal,
103        check_new: bool,
104    ) -> bool {
105        if principal.is_admin || !self.rls_enabled(table) {
106            return true;
107        }
108        let applicable = self.policies.iter().filter(|policy| {
109            policy.table == table
110                && (matches!(policy.command, PolicyCommand::All) || policy.command == command)
111        });
112        let mut has_permissive = false;
113        let mut permissive_allowed = false;
114        let mut restrictive_allowed = true;
115        for policy in applicable.filter(|policy| subject_matches(&policy.subjects, principal)) {
116            let expression = if check_new {
117                policy.with_check.as_ref().or(policy.using.as_ref())
118            } else {
119                policy.using.as_ref()
120            };
121            let allowed = expression.is_some_and(|expression| expression.eval(row, principal));
122            if policy.permissive {
123                has_permissive = true;
124                permissive_allowed |= allowed;
125            } else {
126                restrictive_allowed &= allowed;
127            }
128        }
129        has_permissive && permissive_allowed && restrictive_allowed
130    }
131
132    pub fn apply_masks(&self, table: &str, row: &mut Row, principal: &Principal) {
133        if principal.is_admin {
134            return;
135        }
136        for mask in self.masks.iter().filter(|mask| mask.table == table) {
137            if !mask.exempt_subjects.is_empty() && subject_matches(&mask.exempt_subjects, principal)
138            {
139                continue;
140            }
141            let Some(value) = row.columns.get_mut(&mask.column) else {
142                continue;
143            };
144            *value = mask.strategy.apply(value);
145        }
146    }
147}
148
149impl SecurityExpr {
150    pub fn eval(&self, row: &Row, principal: &Principal) -> bool {
151        match self {
152            SecurityExpr::True => true,
153            SecurityExpr::ColumnEqCurrentUser { column } => {
154                row.columns.get(column).is_some_and(|value| {
155                    value == &Value::Bytes(principal.username.as_bytes().to_vec())
156                })
157            }
158            SecurityExpr::ColumnEqValue { column, value } => row
159                .columns
160                .get(column)
161                .is_some_and(|current| current == value),
162            SecurityExpr::And { left, right } => {
163                left.eval(row, principal) && right.eval(row, principal)
164            }
165            SecurityExpr::Or { left, right } => {
166                left.eval(row, principal) || right.eval(row, principal)
167            }
168            SecurityExpr::Not { expression } => !expression.eval(row, principal),
169        }
170    }
171}
172
173impl MaskStrategy {
174    fn apply(&self, value: &Value) -> Value {
175        match self {
176            MaskStrategy::Null => Value::Null,
177            MaskStrategy::Redact { replacement } => match value {
178                Value::Null => Value::Null,
179                Value::Bytes(_) => Value::Bytes(replacement.as_bytes().to_vec()),
180                _ => Value::Null,
181            },
182            MaskStrategy::Sha256 => match value {
183                Value::Null => Value::Null,
184                Value::Bytes(bytes) => Value::Bytes(
185                    Sha256::digest(bytes)
186                        .iter()
187                        .map(|byte| format!("{byte:02x}"))
188                        .collect::<String>()
189                        .into_bytes(),
190                ),
191                _ => Value::Null,
192            },
193        }
194    }
195}
196
197fn subject_matches(subjects: &[String], principal: &Principal) -> bool {
198    subjects.is_empty()
199        || subjects.iter().any(|subject| {
200            subject.eq_ignore_ascii_case("public")
201                || subject == &principal.username
202                || principal.roles.contains(subject)
203        })
204}
205
206fn default_true() -> bool {
207    true
208}