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/// Security state used to evaluate only scored retrieval candidates.
19pub struct CandidateAuthorization<'a> {
20    pub table: &'a str,
21    pub security: &'a SecurityCatalog,
22    pub principal: &'a Principal,
23}
24
25#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
26pub struct RowPolicy {
27    pub name: String,
28    pub table: String,
29    pub command: PolicyCommand,
30    #[serde(default)]
31    pub subjects: Vec<String>,
32    #[serde(default = "default_true")]
33    pub permissive: bool,
34    pub using: Option<SecurityExpr>,
35    pub with_check: Option<SecurityExpr>,
36}
37
38#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
39#[serde(rename_all = "snake_case")]
40pub enum PolicyCommand {
41    All,
42    Select,
43    Insert,
44    Update,
45    Delete,
46}
47
48#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
49#[serde(tag = "kind", rename_all = "snake_case")]
50pub enum SecurityExpr {
51    True,
52    ColumnEqCurrentUser {
53        column: u16,
54    },
55    ColumnEqValue {
56        column: u16,
57        value: Value,
58    },
59    And {
60        left: Box<SecurityExpr>,
61        right: Box<SecurityExpr>,
62    },
63    Or {
64        left: Box<SecurityExpr>,
65        right: Box<SecurityExpr>,
66    },
67    Not {
68        expression: Box<SecurityExpr>,
69    },
70}
71
72#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
73pub struct ColumnMask {
74    pub name: String,
75    pub table: String,
76    pub column: u16,
77    pub strategy: MaskStrategy,
78    #[serde(default)]
79    pub exempt_subjects: Vec<String>,
80}
81
82#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
83#[serde(tag = "kind", rename_all = "snake_case")]
84pub enum MaskStrategy {
85    Null,
86    Redact { replacement: String },
87    Sha256,
88}
89
90impl SecurityCatalog {
91    pub fn table_has_security(&self, table: &str) -> bool {
92        self.rls_tables.iter().any(|name| name == table)
93            || self.masks.iter().any(|mask| mask.table == table)
94    }
95
96    pub fn table_has_objects(&self, table: &str) -> bool {
97        self.table_has_security(table) || self.policies.iter().any(|policy| policy.table == table)
98    }
99
100    pub fn rls_enabled(&self, table: &str) -> bool {
101        self.rls_tables.iter().any(|name| name == table)
102    }
103
104    pub fn row_allowed(
105        &self,
106        table: &str,
107        command: PolicyCommand,
108        row: &Row,
109        principal: &Principal,
110        check_new: bool,
111    ) -> bool {
112        if principal.is_admin || !self.rls_enabled(table) {
113            return true;
114        }
115        let applicable = self.policies.iter().filter(|policy| {
116            policy.table == table
117                && (matches!(policy.command, PolicyCommand::All) || policy.command == command)
118        });
119        let mut has_permissive = false;
120        let mut permissive_allowed = false;
121        let mut restrictive_allowed = true;
122        for policy in applicable.filter(|policy| subject_matches(&policy.subjects, principal)) {
123            let expression = if check_new {
124                policy.with_check.as_ref().or(policy.using.as_ref())
125            } else {
126                policy.using.as_ref()
127            };
128            let allowed = expression.is_some_and(|expression| expression.eval(row, principal));
129            if policy.permissive {
130                has_permissive = true;
131                permissive_allowed |= allowed;
132            } else {
133                restrictive_allowed &= allowed;
134            }
135        }
136        has_permissive && permissive_allowed && restrictive_allowed
137    }
138
139    pub fn apply_masks(&self, table: &str, row: &mut Row, principal: &Principal) {
140        if principal.is_admin {
141            return;
142        }
143        for mask in self.masks.iter().filter(|mask| mask.table == table) {
144            if !mask.exempt_subjects.is_empty() && subject_matches(&mask.exempt_subjects, principal)
145            {
146                continue;
147            }
148            let Some(value) = row.columns.get_mut(&mask.column) else {
149                continue;
150            };
151            *value = mask.strategy.apply(value);
152        }
153    }
154
155    pub fn apply_masks_to_cells(
156        &self,
157        table: &str,
158        cells: &mut [(u16, Value)],
159        principal: &Principal,
160    ) {
161        if principal.is_admin {
162            return;
163        }
164        for mask in self.masks.iter().filter(|mask| mask.table == table) {
165            if !mask.exempt_subjects.is_empty() && subject_matches(&mask.exempt_subjects, principal)
166            {
167                continue;
168            }
169            if let Some((_, value)) = cells
170                .iter_mut()
171                .find(|(column_id, _)| *column_id == mask.column)
172            {
173                *value = mask.strategy.apply(value);
174            }
175        }
176    }
177
178    /// Columns needed to evaluate applicable SELECT policies for one principal.
179    pub fn select_policy_columns(&self, table: &str, principal: &Principal) -> Vec<u16> {
180        let mut columns = Vec::new();
181        for policy in self.policies.iter().filter(|policy| {
182            policy.table == table
183                && (matches!(policy.command, PolicyCommand::All)
184                    || policy.command == PolicyCommand::Select)
185                && subject_matches(&policy.subjects, principal)
186        }) {
187            if let Some(expression) = &policy.using {
188                expression.collect_columns(&mut columns);
189            }
190        }
191        columns.sort_unstable();
192        columns.dedup();
193        columns
194    }
195}
196
197impl SecurityExpr {
198    pub fn eval(&self, row: &Row, principal: &Principal) -> bool {
199        match self {
200            SecurityExpr::True => true,
201            SecurityExpr::ColumnEqCurrentUser { column } => {
202                row.columns.get(column).is_some_and(|value| {
203                    value == &Value::Bytes(principal.username.as_bytes().to_vec())
204                })
205            }
206            SecurityExpr::ColumnEqValue { column, value } => row
207                .columns
208                .get(column)
209                .is_some_and(|current| current == value),
210            SecurityExpr::And { left, right } => {
211                left.eval(row, principal) && right.eval(row, principal)
212            }
213            SecurityExpr::Or { left, right } => {
214                left.eval(row, principal) || right.eval(row, principal)
215            }
216            SecurityExpr::Not { expression } => !expression.eval(row, principal),
217        }
218    }
219
220    fn collect_columns(&self, columns: &mut Vec<u16>) {
221        match self {
222            SecurityExpr::True => {}
223            SecurityExpr::ColumnEqCurrentUser { column }
224            | SecurityExpr::ColumnEqValue { column, .. } => columns.push(*column),
225            SecurityExpr::And { left, right } | SecurityExpr::Or { left, right } => {
226                left.collect_columns(columns);
227                right.collect_columns(columns);
228            }
229            SecurityExpr::Not { expression } => expression.collect_columns(columns),
230        }
231    }
232}
233
234impl MaskStrategy {
235    fn apply(&self, value: &Value) -> Value {
236        match self {
237            MaskStrategy::Null => Value::Null,
238            MaskStrategy::Redact { replacement } => match value {
239                Value::Null => Value::Null,
240                Value::Bytes(_) => Value::Bytes(replacement.as_bytes().to_vec()),
241                _ => Value::Null,
242            },
243            MaskStrategy::Sha256 => match value {
244                Value::Null => Value::Null,
245                Value::Bytes(bytes) => Value::Bytes(
246                    Sha256::digest(bytes)
247                        .iter()
248                        .map(|byte| format!("{byte:02x}"))
249                        .collect::<String>()
250                        .into_bytes(),
251                ),
252                _ => Value::Null,
253            },
254        }
255    }
256}
257
258fn subject_matches(subjects: &[String], principal: &Principal) -> bool {
259    subjects.is_empty()
260        || subjects.iter().any(|subject| {
261            subject.eq_ignore_ascii_case("public")
262                || subject == &principal.username
263                || principal.roles.contains(subject)
264        })
265}
266
267fn default_true() -> bool {
268    true
269}