Skip to main content

spg_engine/
rls.rs

1//! v7.39 — row-level security enforcement (Phase 1 SELECT `USING`; Phase 2
2//! write side: INSERT/UPDATE `WITH CHECK`, UPDATE/DELETE `USING`).
3//!
4//! The catalog side (policies, the ENABLE/FORCE flags, pg_policy/pg_policies)
5//! landed in Phase 0. Enforcement only applies to a *policy-subject* session (a
6//! non-superuser `SET ROLE`); the default Admin/login session is a superuser
7//! and bypasses RLS entirely — byte-identical to a customer on real PG
8//! connected as a superuser, so every existing path is unaffected.
9//!
10//! Joins (Phase 3): each RLS-enabled operand of a multi-table FROM is wrapped
11//! in a security-barrier subquery `(SELECT * FROM t) alias`, whose single-table
12//! body is filtered by the same pass on re-entry — correct for every join type
13//! (the barrier filters before the join), matching PG's RLS-as-subquery
14//! rewrite. Subqueries elsewhere are covered by the same recursion.
15
16use alloc::boxed::Box;
17use alloc::string::String;
18use alloc::vec::Vec;
19
20use spg_sql::ast::{BinOp, Expr, FromClause, Literal, SelectItem, SelectStatement, TableRef};
21use spg_storage::{Catalog, ColumnSchema, PolicyCmd, Row, TableSchema, Value};
22
23use crate::eval;
24use crate::{Engine, EngineError};
25
26/// Which qual of a policy an enforcement pass reads.
27#[derive(Clone, Copy, PartialEq, Eq)]
28enum QualKind {
29    /// The `USING` visibility qual (SELECT / UPDATE / DELETE).
30    Using,
31    /// The `WITH CHECK` new-row qual (INSERT / UPDATE), falling back to `USING`
32    /// when a policy has no explicit `WITH CHECK`.
33    WithCheck,
34}
35
36impl Engine {
37    /// v7.39 (RLS) Phase 1 — the SELECT `USING` predicate to AND into a
38    /// single-table SELECT's WHERE, or `None` when RLS does not apply.
39    /// Multi-table FROMs are handled earlier by `rls_rewrite_joins`.
40    pub(crate) fn rls_select_predicate(
41        &self,
42        stmt: &SelectStatement,
43    ) -> Result<Option<Expr>, EngineError> {
44        if self.is_superuser() {
45            return Ok(None);
46        }
47        let Some(from) = &stmt.from else {
48            return Ok(None);
49        };
50        let cat = self.active_catalog();
51        // Joins are handled by `rls_rewrite_joins` (each RLS operand is wrapped
52        // in a security-barrier subquery filtered via this same pass), so the
53        // single-table predicate does not apply to a multi-table FROM.
54        if !from.joins.is_empty() {
55            return Ok(None);
56        }
57        if from.primary.lateral_subquery.is_some() {
58            return Ok(None);
59        }
60        let Some(table) = cat.get(&from.primary.name) else {
61            return Ok(None);
62        };
63        if !table.schema().row_security {
64            return Ok(None);
65        }
66        Ok(Some(build_policy_predicate(
67            table.schema(),
68            self.current_role(),
69            &self.users.memberships_of_transitive(self.current_role()),
70            PolicyCmd::Select,
71            QualKind::Using,
72        )))
73    }
74
75    /// v7.39 (RLS) Phase 3 — cross-table joins. For a policy-subject session,
76    /// wrap each RLS-enabled base table in a multi-table FROM into a
77    /// security-barrier subquery `(SELECT * FROM t) alias`. The inner SELECT is
78    /// single-table, so re-entering the executor applies this module's
79    /// single-table USING filter to it — correct for every join type (the
80    /// subquery filters before the join sees the rows), matching PG's
81    /// RLS-as-subquery rewrite. Returns the rewritten statement to re-enter, or
82    /// `None` when nothing needs wrapping.
83    pub(crate) fn rls_rewrite_joins(&self, stmt: &SelectStatement) -> Option<SelectStatement> {
84        if self.is_superuser() {
85            return None;
86        }
87        let from = stmt.from.as_ref()?;
88        if from.joins.is_empty() {
89            return None;
90        }
91        let cat = self.active_catalog();
92        let needs = is_rls_base(&from.primary, cat)
93            || from.joins.iter().any(|j| is_rls_base(&j.table, cat));
94        if !needs {
95            return None;
96        }
97        let mut s = stmt.clone();
98        let from = s.from.as_mut().expect("checked above");
99        wrap_rls_table(&mut from.primary, cat);
100        for j in &mut from.joins {
101            wrap_rls_table(&mut j.table, cat);
102        }
103        Some(s)
104    }
105
106    /// v7.39 (RLS) Phase 2 — the `USING` visibility predicate to AND into an
107    /// UPDATE / DELETE WHERE (a hidden row is silently skipped, `UPDATE 0`).
108    /// `None` when RLS does not apply.
109    pub(crate) fn rls_write_using_predicate(&self, table: &str, cmd: PolicyCmd) -> Option<Expr> {
110        if self.is_superuser() {
111            return None;
112        }
113        let t = self.active_catalog().get(table)?;
114        if !t.schema().row_security {
115            return None;
116        }
117        Some(build_policy_predicate(
118            t.schema(),
119            self.current_role(),
120            &self.users.memberships_of_transitive(self.current_role()),
121            cmd,
122            QualKind::Using,
123        ))
124    }
125
126    /// v7.39 (RLS) Phase 2 — validate every new row against the combined
127    /// `WITH CHECK` predicate for INSERT / UPDATE. A row that does not satisfy
128    /// it raises PG's "new row violates row-level security policy" error.
129    /// No-op for a superuser session or a non-RLS table.
130    pub(crate) fn rls_check_new_rows(
131        &self,
132        table: &str,
133        cmd: PolicyCmd,
134        columns: &[ColumnSchema],
135        rows: &[Vec<Value<'static>>],
136    ) -> Result<(), EngineError> {
137        if self.is_superuser() {
138            return Ok(());
139        }
140        let Some(t) = self.active_catalog().get(table) else {
141            return Ok(());
142        };
143        if !t.schema().row_security {
144            return Ok(());
145        }
146        let pred = build_policy_predicate(
147            t.schema(),
148            self.current_role(),
149            &self.users.memberships_of_transitive(self.current_role()),
150            cmd,
151            QualKind::WithCheck,
152        );
153        let ctx = eval::EvalContext::new(columns, None);
154        for values in rows {
155            let tmp = Row {
156                values: values.clone(),
157            };
158            let v = eval::eval_expr(&pred, &tmp, &ctx).map_err(EngineError::Eval)?;
159            // RLS rejects unless the check is definitely true (false OR NULL
160            // both violate — stricter than a CHECK constraint, matching PG).
161            if !matches!(v, Value::Bool(true)) {
162                return Err(EngineError::Unsupported(alloc::format!(
163                    "new row violates row-level security policy for table {table:?}"
164                )));
165            }
166        }
167        Ok(())
168    }
169}
170
171/// Combine the applicable policies for `target_cmd` into one predicate:
172/// `(OR of permissive) AND (AND of restrictive)`, reading each policy's `USING`
173/// or `WITH CHECK` qual per `kind` (WITH CHECK falls back to USING). Session-
174/// identity functions are folded to the role literal. No applicable permissive
175/// policy → `false` (default-deny for reads; every new row violates for writes).
176fn build_policy_predicate(
177    schema: &TableSchema,
178    role: &str,
179    member_of: &alloc::collections::BTreeSet<alloc::string::String>,
180    target_cmd: PolicyCmd,
181    kind: QualKind,
182) -> Expr {
183    let mut permissive: Vec<Expr> = Vec::new();
184    let mut restrictive: Vec<Expr> = Vec::new();
185    for p in &schema.policies {
186        if !(p.cmd == target_cmd || p.cmd == PolicyCmd::All) {
187            continue;
188        }
189        // roles empty = PUBLIC (applies to everyone).
190        // v7.39 (round 202) — a policy `TO grp` also applies to
191        // transitive MEMBERS of grp (PG role inheritance; the r202
192        // differential showed SPG default-denying a member where PG
193        // granted visibility through the group).
194        if !(p.roles.is_empty()
195            || p.roles.iter().any(|r| {
196                r.eq_ignore_ascii_case(role) || member_of.contains(&r.to_ascii_lowercase())
197            }))
198        {
199            continue;
200        }
201        let src = match kind {
202            QualKind::Using => p.using_expr.as_ref(),
203            QualKind::WithCheck => p.with_check_expr.as_ref().or(p.using_expr.as_ref()),
204        };
205        let Some(src) = src else {
206            // A policy that imposes no qual in this mode places no restriction:
207            // a permissive one allows, a restrictive one is a no-op.
208            if p.permissive {
209                permissive.push(bool_lit(true));
210            }
211            continue;
212        };
213        let term = match spg_sql::parser::parse_expression(src) {
214            Ok(mut e) => {
215                fold_session_identity(&mut e, role);
216                e
217            }
218            Err(_) => bool_lit(false), // corrupt stored qual → fail closed
219        };
220        if p.permissive {
221            permissive.push(term);
222        } else {
223            restrictive.push(term);
224        }
225    }
226    if permissive.is_empty() {
227        return bool_lit(false); // default-deny
228    }
229    let mut pred = or_fold(permissive);
230    for r in restrictive {
231        pred = and(pred, r);
232    }
233    pred
234}
235
236/// Replace the niladic session-identity functions a qual may reference
237/// (`current_user` / `current_role` / `user` → the effective role;
238/// `session_user` → the login) with string literals, so the predicate
239/// evaluates correctly in a context that carries no session GUCs.
240fn fold_session_identity(e: &mut Expr, role: &str) {
241    match e {
242        Expr::FunctionCall { name, args } if args.is_empty() => {
243            match name.to_ascii_lowercase().as_str() {
244                "current_user" | "current_role" | "user" => {
245                    *e = Expr::Literal(Literal::String(String::from(role)));
246                }
247                "session_user" => {
248                    *e = Expr::Literal(Literal::String(String::from("admin")));
249                }
250                _ => {}
251            }
252        }
253        Expr::Binary { lhs, rhs, .. } => {
254            fold_session_identity(lhs, role);
255            fold_session_identity(rhs, role);
256        }
257        Expr::Unary { expr, .. }
258        | Expr::Cast { expr, .. }
259        | Expr::IsNull { expr, .. }
260        | Expr::FieldAccess { base: expr, .. } => fold_session_identity(expr, role),
261        Expr::FunctionCall { args, .. } => {
262            for a in args {
263                fold_session_identity(a, role);
264            }
265        }
266        Expr::Like { expr, pattern, .. } => {
267            fold_session_identity(expr, role);
268            fold_session_identity(pattern, role);
269        }
270        Expr::InList { expr, list, .. } => {
271            fold_session_identity(expr, role);
272            for it in list {
273                fold_session_identity(it, role);
274            }
275        }
276        _ => {}
277    }
278}
279
280/// A FROM operand that is a bare RLS-enabled base table (not already a
281/// subquery / SRF).
282impl Engine {
283    /// v7.37 (round 830) — does this SELECT read a table whose policies bind
284    /// for this session? The streaming executor asks before it claims a
285    /// statement: policy injection happens further down, in
286    /// `exec_bare_select_cancel`, so a shape the streaming path accepts
287    /// never meets it.
288    ///
289    /// That was invisible while `is_superuser` answered true for every
290    /// session without an explicit SET ROLE — nothing was enforced anywhere,
291    /// so nothing could be bypassed. With authenticated identities carrying
292    /// privilege it became measurable immediately: `SELECT upper(val) FROM
293    /// sec` returned the policy's two rows while `SELECT val FROM sec`
294    /// returned all three, same session, same table.
295    pub(crate) fn select_reads_policy_subject_table(&self, stmt: &SelectStatement) -> bool {
296        if self.is_superuser() {
297            return false;
298        }
299        let Some(from) = &stmt.from else {
300            return false;
301        };
302        let cat = self.active_catalog();
303        is_rls_base(&from.primary, cat) || from.joins.iter().any(|j| is_rls_base(&j.table, cat))
304    }
305}
306
307fn is_rls_base(tref: &TableRef, cat: &Catalog) -> bool {
308    tref.lateral_subquery.is_none()
309        && tref.unnest_expr.is_none()
310        && tref.generate_series_args.is_none()
311        && cat.get(&tref.name).is_some_and(|t| t.schema().row_security)
312}
313
314/// Rewrite a bare RLS base-table operand into `(SELECT * FROM base) alias`,
315/// preserving its alias. Non-RLS / already-derived operands are untouched.
316fn wrap_rls_table(tref: &mut TableRef, cat: &Catalog) {
317    if !is_rls_base(tref, cat) {
318        return;
319    }
320    let base = tref.name.clone();
321    let alias = tref.alias.clone().unwrap_or_else(|| base.clone());
322    let inner = SelectStatement {
323        items: alloc::vec![SelectItem::Wildcard],
324        from: Some(FromClause {
325            primary: bare_table_ref(base),
326            joins: Vec::new(),
327        }),
328        ..SelectStatement::default()
329    };
330    tref.name = alias.clone();
331    tref.alias = Some(alias);
332    tref.lateral_subquery = Some(Box::new(inner));
333}
334
335/// A minimal `TableRef` naming a base table with no alias / modifiers.
336fn bare_table_ref(name: String) -> TableRef {
337    TableRef {
338        name,
339        alias: None,
340        only: false,
341        as_of_segment: None,
342        unnest_expr: None,
343        unnest_column_aliases: Vec::new(),
344        with_ordinality: false,
345        generate_series_args: None,
346        lateral_subquery: None,
347        jsonb_each_text_arg: None,
348        table_fn_call: None,
349        scalar_fn_item: false,
350        rows_from: None,
351        json_table: None,
352    }
353}
354
355fn bool_lit(b: bool) -> Expr {
356    Expr::Literal(Literal::Bool(b))
357}
358
359fn and(a: Expr, b: Expr) -> Expr {
360    Expr::Binary {
361        lhs: Box::new(a),
362        op: BinOp::And,
363        rhs: Box::new(b),
364    }
365}
366
367fn or_fold(mut terms: Vec<Expr>) -> Expr {
368    let mut acc = terms.remove(0);
369    for t in terms {
370        acc = Expr::Binary {
371            lhs: Box::new(acc),
372            op: BinOp::Or,
373            rhs: Box::new(t),
374        };
375    }
376    acc
377}