Skip to main content

boatramp_core/
target_sql.rs

1//! Host-side parse-and-rewrite confinement of a guest's **raw-SQL target read** (R4/D8).
2//!
3//! The `orm` binding confines a target read (reading ANOTHER tenant `B`'s PUBLIC subset) per table
4//! via [`TableKeys::PerTableTarget`](crate::orm::TableKeys::PerTableTarget): every accessed table is
5//! rewritten to `tenant = B AND <that table's public predicate>`, and a table with no declared
6//! public subset is refused (deny-by-default). Raw SQL had only the guest-cooperative `{scope}`
7//! marker — a single, single-table, guest-placed injection point that a guest could **reposition**
8//! (leaving joined tables unconfined) or **`OR`-escape** (`WHERE {scope} OR 1=1`). That is
9//! structurally unfixable with a text marker.
10//!
11//! This module closes it by doing to raw SQL what the ORM does to typed queries: it **parses** the
12//! guest statement into an AST and **injects** the same per-table confinement onto EVERY table
13//! reference — the root `FROM`, every `JOIN`, every subquery, CTE, and set-operation arm — at the
14//! AST level, where the guest cannot move or escape it. The guest's own `WHERE` is parenthesised
15//! before the confinement is `AND`-ed on, so a top-level `OR` in the guest predicate can never widen
16//! past the tenant/public gate.
17//!
18//! ## Why this is safe (the completeness argument)
19//!
20//! A single missed table reference is a cross-tenant leak, so completeness cannot rest on a
21//! hand-rolled belief that every AST position has been enumerated. Instead:
22//!
23//! 1. The traversal is sqlparser's derived [`VisitMut`](sqlparser::ast::VisitMut) walk, which is
24//!    maintained by sqlparser to cover the WHOLE grammar. Every `Query` node in the tree — including
25//!    those buried in `IN (SELECT …)`, `EXISTS (…)`, scalar subqueries, derived tables, and
26//!    `UNION`/`INTERSECT`/`EXCEPT` arms — receives a [`pre_visit_query`](Rewriter::pre_visit_query),
27//!    where its own `SELECT`s are confined.
28//! 2. Every table reference lives in a `SELECT`'s `FROM` (directly or under a `NESTED JOIN`), and
29//!    every `SELECT` is confined by exactly one enclosing query's visit — so every base table is
30//!    reached exactly once. With `WITH`/CTEs refused up front, a bare `FROM foo` is ALWAYS a base
31//!    table (derived tables are a distinct AST node, subqueries are their own `Query`), so there is
32//!    no name-shadowing case in which a reference could be mistaken for a non-table and skipped.
33//! 3. Anything the confinement cannot reason about — a table-valued function, `UNNEST`, `PIVOT`, a
34//!    schema-qualified name, a CTE, a write smuggled into a read position, an exotic table source —
35//!    is **refused** (fail-closed), never silently passed. A second
36//!    [`pre_visit_table_factor`](Rewriter::pre_visit_table_factor) guard rejects any un-confinable
37//!    table source anywhere in the tree as belt-and-suspenders.
38//!
39//! The injected `B` and public-subset literals are host-held (from the routing context + the
40//! operator's schema), never guest input, and are rendered through sqlparser's own escaping
41//! ([`Value::SingleQuotedString`](sqlparser::ast::Value) doubles quotes) — so they are safe as
42//! literals and, unlike bound parameters, do not disturb the guest's own positional placeholders
43//! (which matters for the positional-parameter dialects).
44
45use std::collections::BTreeMap;
46use std::ops::ControlFlow;
47
48use sqlparser::ast::{
49    BinaryOperator, Expr, Ident, JoinConstraint, JoinOperator, Query, Select, SetExpr, Statement,
50    TableFactor, Value, VisitMut, VisitorMut,
51};
52use sqlparser::dialect::{Dialect as SpDialect, MySqlDialect, PostgreSqlDialect, SQLiteDialect};
53use sqlparser::parser::Parser;
54
55use crate::orm::{CmpOp, PublicTermSql};
56use crate::sql::{Dialect, SqlValue};
57use crate::tenancy::ResolvedScope;
58
59/// Why a raw-SQL target read is **refused** before it reaches the backend (always fail-closed — a
60/// target read that cannot be provably confined does not run).
61#[derive(Debug, Clone, PartialEq, Eq)]
62pub enum TargetRewriteError {
63    /// The statement did not parse under the backend's dialect.
64    Parse(String),
65    /// Not a single read-only query: multiple statements, or a top-level statement that is not a
66    /// `SELECT` / `VALUES` / set-operation (a write or DDL). Target reads are read-only.
67    NotReadOnly,
68    /// A write was smuggled into a read position (a `SELECT … INTO`, a `TABLE t` shorthand, or an
69    /// `INSERT`/`UPDATE` inside a CTE / set-op arm).
70    WriteInReadPosition,
71    /// A table source the confinement cannot reason about (a table-valued function, `UNNEST`,
72    /// `PIVOT`/`UNPIVOT`, `JSON_TABLE`, `MATCH_RECOGNIZE`, …) — refused rather than left unconfined.
73    UnsupportedTableSource(String),
74    /// A schema-/database-qualified table name (`schema.table`). A target read must use bare table
75    /// identifiers so the per-table key/public lookup is unambiguous (a qualified name could point
76    /// at a different physical table than the schema entry it would be confined by).
77    QualifiedTableName(String),
78    /// A table accessed under a target read declares no PUBLIC subset (deny-by-default — the strict
79    /// analog of the ORM's `PublicSubsetUndeclared`).
80    PublicSubsetUndeclared(String),
81    /// A table accessed under a target read has no declared tenant key in the schema
82    /// (deny-by-default — the analog of `TenancyUndeclared`).
83    TenancyUndeclared(String),
84    /// The tenant `B` value, or a public-subset literal, cannot be rendered as a safe SQL literal
85    /// (a blob / JSON / null / non-finite float where a scalar was required).
86    UnsupportedLiteral,
87    /// A tenant/public column in the schema is not a valid SQL identifier (operator misconfig).
88    BadColumn(String),
89    /// A declared subset lowered to no confinement at all (empty predicate on an unscoped table) —
90    /// would match every row; refused. (The schema validator rejects empty predicates up front;
91    /// this is the injector-level backstop.)
92    EmptyConfinement(String),
93    /// A target read was attempted with no resolved target tenant `B` (the principal carried no
94    /// `TargetTenant` fact). Unreachable by construction — a target principal always resolves `B` —
95    /// but refused fail-closed rather than run unconfined.
96    MissingTarget,
97    /// A join the target-read confinement cannot soundly place: a RIGHT/FULL OUTER join (the driving
98    /// side is nullable), a semi/anti/apply/asof join, or a LEFT OUTER join with a `USING`/`NATURAL`/
99    /// no constraint (no `ON` to inject the confinement into). Refused fail-closed — the ORM target
100    /// path is INNER + LEFT-`ON` only, and the same read is expressible as a `LEFT … ON` join.
101    UnsupportedJoin(String),
102    /// The statement used a `WITH` (CTE). CTEs are refused in a raw-SQL target read (deny-by-default,
103    /// matching the ORM target path, which does not support CTEs): a non-recursive CTE's body may
104    /// reference the base table under the CTE's own name, and a recursive CTE references itself, so a
105    /// name-based "is this a CTE reference?" test cannot soundly distinguish a base-table read from a
106    /// CTE reference — the safe collapse is to refuse. The same read is expressible with a derived
107    /// table / subquery, which IS confined.
108    CteNotAllowed,
109}
110
111impl TargetRewriteError {
112    /// A short, guest-safe reason (no tenant values leaked).
113    pub fn reason(&self) -> String {
114        match self {
115            Self::Parse(m) => format!("tenancy(target): raw SQL did not parse: {m}"),
116            Self::NotReadOnly => {
117                "tenancy(target): a target read must be a single read-only SELECT".into()
118            }
119            Self::WriteInReadPosition => {
120                "tenancy(target): a write is not allowed in a target read".into()
121            }
122            Self::UnsupportedTableSource(s) => {
123                format!("tenancy(target): unsupported table source in a target read: {s}")
124            }
125            Self::QualifiedTableName(t) => format!(
126                "tenancy(target): schema-qualified table name `{t}` is not allowed in a target \
127                 read (use a bare table name)"
128            ),
129            Self::PublicSubsetUndeclared(t) => format!(
130                "tenancy(target): table `{t}` declares no public subset (a target read may only \
131                 reach tables with a declared public subset)"
132            ),
133            Self::TenancyUndeclared(t) => {
134                format!("tenancy(target): table `{t}` is not declared in the tenancy schema")
135            }
136            Self::UnsupportedLiteral => {
137                "tenancy(target): a confinement literal cannot be safely rendered".into()
138            }
139            Self::BadColumn(c) => format!("tenancy(target): misconfigured column `{c}`"),
140            Self::EmptyConfinement(t) => {
141                format!("tenancy(target): table `{t}` lowered to an empty confinement")
142            }
143            Self::MissingTarget => {
144                "tenancy(target): no resolved target tenant for this request".into()
145            }
146            Self::CteNotAllowed => {
147                "tenancy(target): a WITH/CTE is not allowed in a target read (use a subquery or \
148                 derived table)"
149                    .into()
150            }
151            Self::UnsupportedJoin(k) => format!(
152                "tenancy(target): a {k} join is not allowed in a target read (use an INNER join or a \
153                 LEFT … ON join)"
154            ),
155        }
156    }
157}
158
159/// Rewrite a guest's **raw-SQL target read** so every table reference is confined to
160/// `tenant = <tenant_value> AND <that table's public subset>` (R4/D8). `keys` and `public` are the
161/// project schema's per-table tenant-key map and per-table lowered public-subset terms (exactly the
162/// two maps a [`TableKeys::PerTableTarget`](crate::orm::TableKeys::PerTableTarget) carries);
163/// `tenant_value` is the host-resolved target tenant `B` (NEVER guest input); `dialect` selects the
164/// parser. Returns the rewritten SQL text (the guest's own positional params are untouched — `B`
165/// and the public literals are injected as escaped literals), or a [`TargetRewriteError`]
166/// (fail-closed — the read does not run).
167#[allow(clippy::too_many_arguments)] // a confinement rewriter: each arg is a distinct host input.
168pub fn rewrite_target_select(
169    statement: &str,
170    tenant_value: &SqlValue,
171    keys: &BTreeMap<String, ResolvedScope>,
172    public: &BTreeMap<String, Vec<PublicTermSql>>,
173    require_public: bool,
174    // `target_or_null` (v0.4.8): when `true`, a plain tenant (`Column`) table's confinement is
175    // `(col = B OR col IS NULL)` — B's rows ⊕ the shared `NULL`-tenant base rows — instead of
176    // `col = B`. Read-only; ONLY plain `Column` tables (never `TenantOrSession`/`Unscoped`).
177    null_base: bool,
178    dialect: Dialect,
179) -> Result<String, TargetRewriteError> {
180    let sp: Box<dyn SpDialect> = match dialect {
181        Dialect::Sqlite => Box::new(SQLiteDialect {}),
182        Dialect::Postgres => Box::new(PostgreSqlDialect {}),
183        Dialect::Mysql => Box::new(MySqlDialect {}),
184    };
185    let mut statements =
186        Parser::parse_sql(&*sp, statement).map_err(|e| TargetRewriteError::Parse(e.to_string()))?;
187    // Exactly one, read-only, top-level query. A write/DDL, or a multi-statement batch, is refused
188    // here (belt-and-suspenders with the write-axis grant, which is `None` under a target read).
189    if statements.len() != 1 {
190        return Err(TargetRewriteError::NotReadOnly);
191    }
192    match &statements[0] {
193        Statement::Query(_) => {}
194        _ => return Err(TargetRewriteError::NotReadOnly),
195    }
196    // Pre-render B once (fail-closed on a value we cannot render safely as a literal).
197    let bound = value_expr(tenant_value)?;
198    let mut rewriter = Rewriter {
199        keys,
200        public,
201        require_public,
202        bound,
203        null_base,
204    };
205    if let ControlFlow::Break(err) = statements[0].visit(&mut rewriter) {
206        return Err(err);
207    }
208    Ok(statements[0].to_string())
209}
210
211/// Best-effort extraction of the single tenant value a **raw-SQL `all` write** declares, so the host
212/// can set the RLS tenant GUC to it (v0.4.20 — the raw-path analog of
213/// [`Insert::uniform_scope_value`](crate::orm::Insert::uniform_scope_value) /
214/// [`Update::pinned_scope_value`](crate::orm::Update::pinned_scope_value)). Parses `statement` and:
215/// - **INSERT** → the uniform literal value of column `col` across all `VALUES` rows (`None` if `col`
216///   is missing, a non-literal, rows disagree, an `INSERT … SELECT`, or not a single INSERT);
217/// - **UPDATE** → the literal the WHERE pins `col` to at top level or within `AND`s (`None` for an
218///   `OR`/`IN`/range/unpinned filter, or contradictory pins);
219/// - anything else → `None`.
220///
221/// `col_for_table` maps the statement's target table to the tenant/scope column an RLS policy keys
222/// on (the caller resolves it from the project schema — the identity table on its own PK, a data
223/// table on `tenant_id`, an `Unscoped`/undeclared table to `None`). Returning `None` there ⇒ no GUC.
224///
225/// `None` fails **closed**: the GUC isn't re-set, so it keeps the prior per-transaction value (or
226/// stays unset if none) — either way the DB's RLS (`WITH CHECK`/`USING`) can only *over*-restrict the
227/// write, never widen it. The DB is the final arbiter, so a conservative (over-`None`) extractor is
228/// safe — it can only make a legitimate write fail, never permit a cross-tenant one. Guest input never
229/// reaches a predicate or the GUC name; only the *value* the write already carries sets the GUC.
230pub fn extract_raw_write_scope_value(
231    statement: &str,
232    dialect: Dialect,
233    col_for_table: impl Fn(&str) -> Option<String>,
234) -> Option<SqlValue> {
235    use sqlparser::ast::{BinaryOperator, Expr as E, SetExpr, Statement, TableFactor, Value as V};
236
237    fn lit(v: &V) -> Option<SqlValue> {
238        match v {
239            V::SingleQuotedString(s) | V::DoubleQuotedString(s) => Some(SqlValue::Text(s.clone())),
240            V::Number(n, _) => Some(
241                n.parse::<i64>()
242                    .map(SqlValue::Integer)
243                    .unwrap_or_else(|_| SqlValue::Text(n.clone())),
244            ),
245            V::Boolean(b) => Some(SqlValue::Boolean(*b)),
246            _ => None,
247        }
248    }
249    fn col_name(e: &E) -> Option<String> {
250        match e {
251            E::Identifier(id) => Some(id.value.clone()),
252            E::CompoundIdentifier(ids) => ids.last().map(|i| i.value.clone()),
253            _ => None,
254        }
255    }
256    // The literal `col = <lit>` pinned by a WHERE `Expr` at top level or within `AND`s.
257    fn pinned(e: &E, col: &str) -> Option<SqlValue> {
258        match e {
259            E::BinaryOp {
260                left,
261                op: BinaryOperator::Eq,
262                right,
263            } => {
264                if col_name(left).is_some_and(|c| c.eq_ignore_ascii_case(col)) {
265                    if let E::Value(v) = right.as_ref() {
266                        return lit(v);
267                    }
268                }
269                if col_name(right).is_some_and(|c| c.eq_ignore_ascii_case(col)) {
270                    if let E::Value(v) = left.as_ref() {
271                        return lit(v);
272                    }
273                }
274                None
275            }
276            E::BinaryOp {
277                left,
278                op: BinaryOperator::And,
279                right,
280            } => match (pinned(left, col), pinned(right, col)) {
281                (Some(a), Some(b)) if a == b => Some(a),
282                (Some(a), None) | (None, Some(a)) => Some(a),
283                _ => None, // both sides pin different tenants (contradiction) → fail closed
284            },
285            E::Nested(inner) => pinned(inner, col),
286            _ => None,
287        }
288    }
289
290    let sp: Box<dyn SpDialect> = match dialect {
291        Dialect::Sqlite => Box::new(SQLiteDialect {}),
292        Dialect::Postgres => Box::new(PostgreSqlDialect {}),
293        Dialect::Mysql => Box::new(MySqlDialect {}),
294    };
295    let stmts = Parser::parse_sql(&*sp, statement).ok()?;
296    if stmts.len() != 1 {
297        return None;
298    }
299    // The last identifier of an `ObjectName` (`schema.table` → `table`), lowercased for lookup.
300    let table_of = |name: &sqlparser::ast::ObjectName| -> Option<String> {
301        name.0.last().map(|i| i.value.clone())
302    };
303    match &stmts[0] {
304        Statement::Insert(ins) => {
305            let col = col_for_table(&table_of(&ins.table_name)?)?;
306            let idx = ins
307                .columns
308                .iter()
309                .position(|c| c.value.eq_ignore_ascii_case(&col))?;
310            let rows = match ins.source.as_ref()?.body.as_ref() {
311                SetExpr::Values(vals) => &vals.rows,
312                _ => return None, // INSERT … SELECT (or other) — no literal row values
313            };
314            if rows.is_empty() {
315                return None;
316            }
317            let mut found: Option<SqlValue> = None;
318            for row in rows {
319                let v = match row.get(idx)? {
320                    E::Value(v) => lit(v)?,
321                    _ => return None,
322                };
323                match &found {
324                    None => found = Some(v),
325                    Some(prev) if *prev == v => {}
326                    Some(_) => return None,
327                }
328            }
329            found
330        }
331        Statement::Update {
332            table,
333            selection: Some(where_),
334            ..
335        } => {
336            let name = match &table.relation {
337                TableFactor::Table { name, .. } => name,
338                _ => return None,
339            };
340            let col = col_for_table(&table_of(name)?)?;
341            pinned(where_, &col)
342        }
343        _ => None,
344    }
345}
346
347/// The mutating visitor that injects the per-table confinement. `WITH`/CTEs are refused up front
348/// (see [`TargetRewriteError::CteNotAllowed`]), so — because derived tables are `TableFactor::Derived`
349/// and subqueries are their own `Query` nodes — a `TableFactor::Table` bare name is ALWAYS a base
350/// table (never a CTE reference). That removes the need to track a CTE-name scope, and with it the
351/// scope foot-gun class entirely: every base table is unconditionally confined.
352struct Rewriter<'a> {
353    keys: &'a BTreeMap<String, ResolvedScope>,
354    public: &'a BTreeMap<String, Vec<PublicTermSql>>,
355    /// Whether a per-table public subset is mandatory (R4/D8 5c ruling A): `true` for domain/handle
356    /// (an undeclared subset ⇒ refuse); `false` for a `capability`-only field (an undeclared subset ⇒
357    /// confine to `tenant = B` alone — the capability is the authorization).
358    require_public: bool,
359    /// The host-resolved target tenant `B`, pre-rendered as a literal expression.
360    bound: Expr,
361    /// `target_or_null` (v0.4.8): widen a plain `Column` table's tenant confinement from `col = B`
362    /// to `(col = B OR col IS NULL)` — B ⊕ the shared `NULL`-tenant base rows.
363    null_base: bool,
364}
365
366impl VisitorMut for Rewriter<'_> {
367    type Break = TargetRewriteError;
368
369    fn pre_visit_query(&mut self, query: &mut Query) -> ControlFlow<Self::Break> {
370        // Refuse any CTE (deny-by-default): a non-recursive CTE body may reference the base table
371        // under the CTE's own name, and a recursive CTE references itself, so a name-based test
372        // cannot soundly tell a base-table read from a CTE reference. The same read is expressible
373        // with a derived table / subquery, which is confined.
374        if query.with.is_some() {
375            return ControlFlow::Break(TargetRewriteError::CteNotAllowed);
376        }
377        // Confine every SELECT directly in this query's body (through set-operation arms). Nested
378        // queries (derived tables, expression subqueries, `SetExpr::Query`) are separate `Query`
379        // nodes and receive their own `pre_visit_query`.
380        if let Err(e) = self.confine_body(&mut query.body) {
381            return ControlFlow::Break(e);
382        }
383        ControlFlow::Continue(())
384    }
385
386    fn pre_visit_table_factor(
387        &mut self,
388        table_factor: &mut TableFactor,
389    ) -> ControlFlow<Self::Break> {
390        // Belt-and-suspenders: refuse any un-confinable table source ANYWHERE in the tree, resting on
391        // sqlparser's exhaustive traversal rather than on the confinement walk reaching every FROM.
392        // (Recognised sources — a bare base table, a derived subquery, a nested join — pass; the
393        // confinement itself is applied per-SELECT in `confine_body`.)
394        match table_factor {
395            TableFactor::Table { args: Some(_), .. } => ControlFlow::Break(
396                TargetRewriteError::UnsupportedTableSource("table-valued function".into()),
397            ),
398            TableFactor::Table { name, .. } if name.0.len() != 1 => ControlFlow::Break(
399                TargetRewriteError::QualifiedTableName(object_name_string(name)),
400            ),
401            TableFactor::Table { .. }
402            | TableFactor::Derived { .. }
403            | TableFactor::NestedJoin { .. } => ControlFlow::Continue(()),
404            other => ControlFlow::Break(TargetRewriteError::UnsupportedTableSource(
405                table_factor_kind(other).into(),
406            )),
407        }
408    }
409}
410
411impl Rewriter<'_> {
412    /// Confine every `SELECT` reachable in this `SetExpr` at THIS query level (through set-operation
413    /// arms), refusing writes smuggled into a read position. Nested `Query` nodes are left to their
414    /// own `pre_visit_query`.
415    fn confine_body(&self, body: &mut SetExpr) -> Result<(), TargetRewriteError> {
416        match body {
417            SetExpr::Select(select) => self.confine_select(select),
418            SetExpr::SetOperation { left, right, .. } => {
419                self.confine_body(left)?;
420                self.confine_body(right)
421            }
422            // A nested parenthesised query / constant rows: handled by the query's own visit (a
423            // subquery inside a VALUES row is itself a `Query` node and is confined there).
424            SetExpr::Query(_) | SetExpr::Values(_) => Ok(()),
425            // Writes are never a target read.
426            SetExpr::Insert(_) | SetExpr::Update(_) | SetExpr::Table(_) => {
427                Err(TargetRewriteError::WriteInReadPosition)
428            }
429        }
430    }
431
432    /// Conjoin `tenant = B AND <public>` for each base table in this `SELECT` onto the RIGHT
433    /// position: the driving relation and every INNER/CROSS-joined table go onto the top-level
434    /// `WHERE` (the guest's own `WHERE` parenthesised first, so a top-level `OR` cannot widen past
435    /// the gate — closes M2); a **LEFT-OUTER**-joined table's confinement goes onto that join's own
436    /// `ON` (its guest `ON` parenthesised first). Confining a LEFT-joined table in the top-level
437    /// `WHERE` would collapse the LEFT JOIN to an INNER JOIN — dropping the driving row when there is
438    /// no match — so a routed tenant with no matching joined row would vanish and a SELECT-list
439    /// `COALESCE(joined.col, driving.col)` fallback would never fire. In the `ON`, an unmatched /
440    /// other-tenant row instead becomes `NULL` (never a cross-tenant bleed — the tenant/public gate
441    /// is AND-ed into the join condition), and the fallback correctly resolves to the (confined)
442    /// driving value. CTE references and derived tables are skipped (confined at their own level).
443    /// RIGHT/FULL OUTER, semi/anti/apply/asof joins, and a LEFT OUTER with a `USING`/`NATURAL`/no
444    /// constraint are refused fail-closed: the ORM target path is INNER + LEFT-`ON` only, and the
445    /// same read is expressible as a `LEFT … ON` join.
446    fn confine_select(&self, select: &mut Select) -> Result<(), TargetRewriteError> {
447        // `SELECT … INTO t` materialises a table — a write in a read position.
448        if select.into.is_some() {
449            return Err(TargetRewriteError::WriteInReadPosition);
450        }
451        // Confinement destined for the top-level `WHERE`: the non-nullable positions — the driving
452        // relation and every INNER/CROSS join. A LEFT-OUTER join injects into its own `ON` below.
453        let mut where_conf: Option<Expr> = None;
454        for twj in &mut select.from {
455            self.accumulate_relation(&twj.relation, &mut where_conf)?;
456            for join in &mut twj.joins {
457                match &mut join.join_operator {
458                    JoinOperator::Inner(_) | JoinOperator::CrossJoin => {
459                        self.accumulate_relation(&join.relation, &mut where_conf)?;
460                    }
461                    JoinOperator::LeftOuter(JoinConstraint::On(on)) => {
462                        let mut on_conf: Option<Expr> = None;
463                        self.accumulate_relation(&join.relation, &mut on_conf)?;
464                        if let Some(conf) = on_conf {
465                            // `(<guest ON>) AND <confinement>` — the same OR-escape closure as the
466                            // WHERE path, in the join's own ON so LEFT-JOIN semantics are preserved.
467                            let existing = on.clone();
468                            *on = and(Expr::Nested(Box::new(existing)), conf);
469                        }
470                    }
471                    other => {
472                        return Err(TargetRewriteError::UnsupportedJoin(
473                            join_operator_kind(other).into(),
474                        ))
475                    }
476                }
477            }
478        }
479        let Some(confinement) = where_conf else {
480            // No base table needed a WHERE predicate (all confinement landed in LEFT-join ONs, or a
481            // capability field's global reference tables) — nothing to add here.
482            return Ok(());
483        };
484        select.selection = Some(match select.selection.take() {
485            // Parenthesise the guest's predicate: `(<guest WHERE>) AND <confinement>` — a top-level
486            // OR in the guest predicate can never escape the tenant/public gate (closes M2).
487            Some(existing) => and(Expr::Nested(Box::new(existing)), confinement),
488            None => confinement,
489        });
490        Ok(())
491    }
492
493    /// Accumulate (via `AND`) the confinement predicate for every base table in a `FROM` factor —
494    /// its own table, plus, for a `NESTED JOIN`, each nested relation — into `acc`. Derived tables
495    /// and CTE references are skipped (confined at their own query level). A table-valued function,
496    /// a schema-qualified name, or any un-confinable source is refused fail-closed. (A base table
497    /// that needs no predicate — a `capability` field's global `Unscoped` reference table — adds
498    /// nothing; the confined tables still gate the row set.)
499    fn accumulate_relation(
500        &self,
501        factor: &TableFactor,
502        acc: &mut Option<Expr>,
503    ) -> Result<(), TargetRewriteError> {
504        match factor {
505            TableFactor::Table { args: Some(_), .. } => Err(
506                TargetRewriteError::UnsupportedTableSource("table-valued function".into()),
507            ),
508            TableFactor::Table { name, alias, .. } => {
509                if name.0.len() != 1 {
510                    return Err(TargetRewriteError::QualifiedTableName(object_name_string(
511                        name,
512                    )));
513                }
514                // With CTEs refused, a bare `TableFactor::Table` is unconditionally a base table.
515                let base = name.0[0].value.clone();
516                // The qualifier columns will be referenced by: the alias if present, else the
517                // table's own identifier (cloned to preserve any quoting).
518                let qualifier = alias
519                    .as_ref()
520                    .map(|a| a.name.clone())
521                    .unwrap_or_else(|| name.0[0].clone());
522                if let Some(pred) = self.table_confinement(&base, &qualifier)? {
523                    *acc = Some(match acc.take() {
524                        Some(a) => and(a, pred),
525                        None => pred,
526                    });
527                }
528                Ok(())
529            }
530            // A derived table is a nested `Query` — confined by its own `pre_visit_query`; its alias
531            // is a logical name, not a base table.
532            TableFactor::Derived { .. } => Ok(()),
533            TableFactor::NestedJoin {
534                table_with_joins, ..
535            } => {
536                self.accumulate_relation(&table_with_joins.relation, acc)?;
537                for join in &table_with_joins.joins {
538                    self.accumulate_relation(&join.relation, acc)?;
539                }
540                Ok(())
541            }
542            other => Err(TargetRewriteError::UnsupportedTableSource(
543                table_factor_kind(other).into(),
544            )),
545        }
546    }
547
548    /// The confinement predicate for one base table: `qualifier.tenant = B` (unless the table is
549    /// `Unscoped`) `AND` the table's public-subset terms (each qualified). `Ok(None)` when the table
550    /// needs no predicate at all (a `capability`-only field's global/`Unscoped` reference table).
551    /// Deny-by-default: a table with no declared tenant key is refused; under `require_public`
552    /// (domain/handle) a table with no declared public subset is refused.
553    fn table_confinement(
554        &self,
555        table: &str,
556        qualifier: &Ident,
557    ) -> Result<Option<Expr>, TargetRewriteError> {
558        // The public terms. Ruling A (v0.4.4), made COMPLETE: under a `capability`-only field
559        // (`!require_public`) the visibility subset is INERT — confine to `tenant = B` alone
560        // (applied below) and apply NO per-table subset, not even a DECLARED one. A subset authored
561        // for the anonymous domain/handle funnel (e.g. `client_id IS NULL`) must not narrow a
562        // capability read: it collides with the resolver's own in-guest per-`sub` filter and would
563        // empty the result. The host-verified, project-audience-bound capability + that in-guest
564        // filter is the authorization. Under `require_public` (domain/handle — the visibility
565        // predicate is the only guard for an anonymous actor; and any `target_or_null`, whose shared
566        // NULL-base arm v0.4.8 forces the subset even under a capability) a declared subset is
567        // applied and an undeclared one is refused (deny-by-default).
568        let empty: Vec<PublicTermSql> = Vec::new();
569        let terms = if !self.require_public {
570            &empty
571        } else {
572            match self.public.get(table) {
573                Some(t) => t,
574                None => {
575                    return Err(TargetRewriteError::PublicSubsetUndeclared(
576                        table.to_string(),
577                    ))
578                }
579            }
580        };
581        let resolved = self
582            .keys
583            .get(table)
584            .ok_or_else(|| TargetRewriteError::TenancyUndeclared(table.to_string()))?;
585
586        let mut parts: Vec<Expr> = Vec::new();
587        match resolved {
588            ResolvedScope::Column(col) => {
589                check_ident(col)?;
590                let eq = binop(
591                    col_expr(qualifier, col),
592                    BinaryOperator::Eq,
593                    self.bound.clone(),
594                );
595                // `target_or_null`: `(col = B OR col IS NULL)` — B's rows ⊕ the shared base. ONLY on
596                // a plain `Column` (tenant) table; the `TenantOrSession` arm below never ORs in NULL
597                // (its NULL partition is session rows, not shared base — that would leak).
598                parts.push(if self.null_base {
599                    Expr::Nested(Box::new(or(
600                        eq,
601                        Expr::IsNull(Box::new(col_expr(qualifier, col))),
602                    )))
603                } else {
604                    eq
605                });
606            }
607            // A base-inclusive table folds its shared `tenant IS NULL` base into EVERY target read:
608            // `(col = B OR col IS NULL)`, regardless of the field's `null_base` — the per-table analog
609            // of `target_or_null`. The per-tenant (non-NULL) rows keep the `tenant = B` boundary; only
610            // the NULL rows are shared. (The public subset below still applies on the target axis.)
611            ResolvedScope::TenantOrBase { tenant } => {
612                check_ident(tenant)?;
613                let eq = binop(
614                    col_expr(qualifier, tenant),
615                    BinaryOperator::Eq,
616                    self.bound.clone(),
617                );
618                parts.push(Expr::Nested(Box::new(or(
619                    eq,
620                    Expr::IsNull(Box::new(col_expr(qualifier, tenant))),
621                ))));
622            }
623            // A globally-readable table carries no tenant predicate — only its public subset (which
624            // must still be declared and non-empty, exactly as the ORM target path requires).
625            ResolvedScope::Unscoped => {}
626            // Under a target read the principal carries only the `TargetTenant` fact `B` (no session
627            // fact), so the R3 disjunct collapses to the single tenant arm `tenant = B`.
628            ResolvedScope::TenantOrSession { tenant, .. } => {
629                check_ident(tenant)?;
630                parts.push(binop(
631                    col_expr(qualifier, tenant),
632                    BinaryOperator::Eq,
633                    self.bound.clone(),
634                ));
635            }
636        }
637        for term in terms {
638            match term {
639                PublicTermSql::Cmp { column, op, value } => {
640                    check_ident(column)?;
641                    parts.push(binop(
642                        col_expr(qualifier, column),
643                        cmp_operator(*op),
644                        value_expr(value)?,
645                    ));
646                }
647                PublicTermSql::Null { column, negated } => {
648                    check_ident(column)?;
649                    let e = Box::new(col_expr(qualifier, column));
650                    parts.push(if *negated {
651                        Expr::IsNotNull(e)
652                    } else {
653                        Expr::IsNull(e)
654                    });
655                }
656            }
657        }
658        // AND all parts. Empty ⇒ no confinement for this table: an `Unscoped` global reference table
659        // under a `capability`-only field (no tenant column, no declared subset) — read globally,
660        // exactly as the own/GDC paths treat `Unscoped`. Under `require_public` this is unreachable
661        // (a Column table always adds `tenant = B`; an `Unscoped`/undeclared-subset table was already
662        // refused), so a domain/handle read can never end up unconfined.
663        let mut it = parts.into_iter();
664        let Some(first) = it.next() else {
665            return Ok(None);
666        };
667        Ok(Some(it.fold(first, and)))
668    }
669}
670
671/// `<qualifier>.<column>` as a compound identifier (the qualifier `Ident` is cloned as-is to
672/// preserve any quoting of the table name/alias).
673fn col_expr(qualifier: &Ident, column: &str) -> Expr {
674    Expr::CompoundIdentifier(vec![qualifier.clone(), Ident::new(column)])
675}
676
677fn binop(left: Expr, op: BinaryOperator, right: Expr) -> Expr {
678    Expr::BinaryOp {
679        left: Box::new(left),
680        op,
681        right: Box::new(right),
682    }
683}
684
685/// `left AND right`.
686fn and(left: Expr, right: Expr) -> Expr {
687    binop(left, BinaryOperator::And, right)
688}
689
690/// `left OR right` — the `target_or_null` tenant disjunct (`col = B OR col IS NULL`).
691fn or(left: Expr, right: Expr) -> Expr {
692    binop(left, BinaryOperator::Or, right)
693}
694
695fn cmp_operator(op: CmpOp) -> BinaryOperator {
696    match op {
697        CmpOp::Eq => BinaryOperator::Eq,
698        CmpOp::Ne => BinaryOperator::NotEq,
699        CmpOp::Lt => BinaryOperator::Lt,
700        CmpOp::Le => BinaryOperator::LtEq,
701        CmpOp::Gt => BinaryOperator::Gt,
702        CmpOp::Ge => BinaryOperator::GtEq,
703    }
704}
705
706/// Render a host-held [`SqlValue`] as a safe SQL literal expression. Text is single-quoted through
707/// sqlparser's escaping (doubles embedded quotes); numbers/booleans are rendered verbatim. A blob,
708/// JSON, NULL, or non-finite float — none of which a tenant value or a lowered public literal is —
709/// is refused (fail-closed) rather than rendered ambiguously.
710fn value_expr(value: &SqlValue) -> Result<Expr, TargetRewriteError> {
711    Ok(Expr::Value(match value {
712        SqlValue::Text(s) => Value::SingleQuotedString(s.clone()),
713        SqlValue::Integer(i) => Value::Number(i.to_string(), false),
714        SqlValue::Boolean(b) => Value::Boolean(*b),
715        SqlValue::Real(f) if f.is_finite() => Value::Number(f.to_string(), false),
716        SqlValue::Real(_) | SqlValue::Null | SqlValue::Blob(_) | SqlValue::Json(_) => {
717            return Err(TargetRewriteError::UnsupportedLiteral)
718        }
719    }))
720}
721
722/// A conservative SQL-identifier check (matches the tenant-column check on the applied side):
723/// non-empty, ASCII alphanumeric or `_`, not starting with a digit.
724fn check_ident(s: &str) -> Result<(), TargetRewriteError> {
725    let mut chars = s.chars();
726    let ok = matches!(chars.next(), Some(c) if c.is_ascii_alphabetic() || c == '_')
727        && chars.all(|c| c.is_ascii_alphanumeric() || c == '_');
728    if ok {
729        Ok(())
730    } else {
731        Err(TargetRewriteError::BadColumn(s.to_string()))
732    }
733}
734
735/// A dotted rendering of a (rejected) qualified table name, for the error message only.
736fn object_name_string(name: &sqlparser::ast::ObjectName) -> String {
737    name.0
738        .iter()
739        .map(|i| i.value.clone())
740        .collect::<Vec<_>>()
741        .join(".")
742}
743
744/// A short label for a rejected exotic table factor (error message only).
745fn table_factor_kind(factor: &TableFactor) -> &'static str {
746    match factor {
747        TableFactor::Table { .. } => "table",
748        TableFactor::Derived { .. } => "derived subquery",
749        TableFactor::TableFunction { .. } => "table function",
750        TableFactor::Function { .. } => "function",
751        TableFactor::UNNEST { .. } => "UNNEST",
752        TableFactor::JsonTable { .. } => "JSON_TABLE",
753        TableFactor::OpenJsonTable { .. } => "OPENJSON",
754        TableFactor::NestedJoin { .. } => "nested join",
755        TableFactor::Pivot { .. } => "PIVOT",
756        TableFactor::Unpivot { .. } => "UNPIVOT",
757        TableFactor::MatchRecognize { .. } => "MATCH_RECOGNIZE",
758    }
759}
760
761/// A short, guest-safe name for a join operator the target-read confinement refuses (used only in
762/// the `UnsupportedJoin` error). INNER + `CROSS` + LEFT-`ON` are handled and never reach here.
763fn join_operator_kind(op: &JoinOperator) -> &'static str {
764    match op {
765        JoinOperator::RightOuter(_) => "RIGHT OUTER",
766        JoinOperator::FullOuter(_) => "FULL OUTER",
767        JoinOperator::LeftOuter(_) => "LEFT OUTER (USING/NATURAL)",
768        JoinOperator::Semi(_) | JoinOperator::LeftSemi(_) | JoinOperator::RightSemi(_) => "SEMI",
769        JoinOperator::Anti(_) | JoinOperator::LeftAnti(_) | JoinOperator::RightAnti(_) => "ANTI",
770        JoinOperator::CrossApply | JoinOperator::OuterApply => "APPLY",
771        JoinOperator::AsOf { .. } => "ASOF",
772        // INNER + CROSS are handled; LEFT-`ON` is handled. Anything else is an unsupported outer join.
773        JoinOperator::Inner(_) | JoinOperator::CrossJoin => "unsupported",
774    }
775}
776
777#[cfg(test)]
778mod raw_write_scope_value_tests {
779    use super::*;
780
781    // Resolve the tenant column: `tenant`'s identity PK is `id`, everything else `tenant_id`;
782    // an `unscoped` table has none.
783    fn col_for(t: &str) -> Option<String> {
784        match t {
785            "tenant" => Some("id".into()),
786            "unscoped" => None,
787            _ => Some("tenant_id".into()),
788        }
789    }
790    fn extract(sql: &str) -> Option<SqlValue> {
791        extract_raw_write_scope_value(sql, Dialect::Postgres, col_for)
792    }
793    fn text(s: &str) -> SqlValue {
794        SqlValue::Text(s.to_string())
795    }
796
797    #[test]
798    fn insert_and_update_extract_a_single_declared_tenant_else_none() {
799        // INSERT single row → the row's tenant.
800        assert_eq!(
801            extract("INSERT INTO audit_event (tenant_id, kind) VALUES ('A','x')"),
802            Some(text("A"))
803        );
804        // A TenantKeyed identity table resolves its PK column.
805        assert_eq!(
806            extract("INSERT INTO tenant (id, name) VALUES ('A','Acme')"),
807            Some(text("A"))
808        );
809        // Multi-row agreeing → the shared value; disagreeing → None.
810        assert_eq!(
811            extract("INSERT INTO audit_event (tenant_id, kind) VALUES ('A','x'),('A','y')"),
812            Some(text("A"))
813        );
814        assert_eq!(
815            extract("INSERT INTO audit_event (tenant_id, kind) VALUES ('A','x'),('B','y')"),
816            None
817        );
818        // UPDATE pinned by the WHERE → the tenant; unpinned / OR → None.
819        assert_eq!(
820            extract("UPDATE audit_event SET kind='x' WHERE tenant_id = 'A'"),
821            Some(text("A"))
822        );
823        assert_eq!(
824            extract("UPDATE audit_event SET kind='x' WHERE tenant_id='A' AND kind='k'"),
825            Some(text("A"))
826        );
827        assert_eq!(
828            extract("UPDATE audit_event SET kind='x' WHERE tenant_id='A' OR tenant_id='B'"),
829            None
830        );
831        assert_eq!(
832            extract("UPDATE audit_event SET kind='x' WHERE kind='k'"),
833            None
834        );
835        // No per-tenant column (unscoped table) → None.
836        assert_eq!(
837            extract("INSERT INTO unscoped (tenant_id) VALUES ('A')"),
838            None
839        );
840        // INSERT … SELECT, a read, or garbage → None (fail-closed).
841        assert_eq!(
842            extract("INSERT INTO audit_event (tenant_id) SELECT tenant_id FROM other"),
843            None
844        );
845        assert_eq!(extract("SELECT 1"), None);
846        assert_eq!(extract("not sql at all ;;"), None);
847    }
848}
849
850#[cfg(test)]
851mod tests {
852    use super::*;
853
854    fn keys() -> BTreeMap<String, ResolvedScope> {
855        BTreeMap::from([
856            (
857                "products".to_string(),
858                ResolvedScope::Column("tenant_id".to_string()),
859            ),
860            (
861                "reviews".to_string(),
862                ResolvedScope::Column("tenant_id".to_string()),
863            ),
864            ("countries".to_string(), ResolvedScope::Unscoped),
865        ])
866    }
867
868    fn public() -> BTreeMap<String, Vec<PublicTermSql>> {
869        BTreeMap::from([
870            (
871                "products".to_string(),
872                vec![PublicTermSql::Cmp {
873                    column: "published".to_string(),
874                    op: CmpOp::Eq,
875                    value: SqlValue::Boolean(true),
876                }],
877            ),
878            (
879                "reviews".to_string(),
880                vec![PublicTermSql::Cmp {
881                    column: "visible".to_string(),
882                    op: CmpOp::Eq,
883                    value: SqlValue::Boolean(true),
884                }],
885            ),
886        ])
887    }
888
889    fn b() -> SqlValue {
890        SqlValue::Text("tenant_B".to_string())
891    }
892
893    fn rewrite(sql: &str) -> Result<String, TargetRewriteError> {
894        // Default helper tests the anonymous (domain/handle) path: public subset mandatory.
895        rewrite_target_select(sql, &b(), &keys(), &public(), true, false, Dialect::Sqlite)
896    }
897
898    /// Rewrite under a `capability`-only field (`require_public = false`): a table with no declared
899    /// public subset confines to `tenant = B` alone.
900    fn rewrite_cap(sql: &str) -> Result<String, TargetRewriteError> {
901        rewrite_target_select(sql, &b(), &keys(), &public(), false, false, Dialect::Sqlite)
902    }
903
904    /// Rewrite under `target_or_null` (`null_base = true`): a plain tenant table's confinement widens
905    /// to `(col = B OR col IS NULL)` — B's rows ⊕ the shared base — still AND the public subset.
906    fn rewrite_null_base(sql: &str) -> Result<String, TargetRewriteError> {
907        rewrite_target_select(sql, &b(), &keys(), &public(), true, true, Dialect::Sqlite)
908    }
909
910    #[test]
911    fn target_or_null_widens_a_tenant_table_to_include_the_null_base() {
912        // The base⊕B read: B's rows OR the shared `NULL`-tenant base rows, still confined to the
913        // public subset. The OR is parenthesized so the AND-ed public term can't rebind it.
914        let out = rewrite_null_base("SELECT id FROM products").unwrap();
915        assert_eq!(
916            out,
917            "SELECT id FROM products WHERE (products.tenant_id = 'tenant_B' OR products.tenant_id IS NULL) AND products.published = true"
918        );
919        // `target` (null_base = false) still reads B alone — no base leak into the non-null-base case.
920        assert_eq!(
921            rewrite("SELECT id FROM products").unwrap(),
922            "SELECT id FROM products WHERE products.tenant_id = 'tenant_B' AND products.published = true"
923        );
924    }
925
926    #[test]
927    fn tenant_or_base_table_folds_the_null_base_under_a_plain_target_read() {
928        // v0.4.16: a PER-TABLE base-inclusive `pack` folds `(tenant = B OR tenant IS NULL)` even under
929        // a plain `target` field (null_base = false) — B's packs ⊕ the shared base — while a plain
930        // `products` table in the SAME read stays `tenant = B` alone. No field-level widening.
931        let keys = BTreeMap::from([
932            (
933                "pack".to_string(),
934                ResolvedScope::TenantOrBase {
935                    tenant: "tenant_id".to_string(),
936                },
937            ),
938            (
939                "products".to_string(),
940                ResolvedScope::Column("tenant_id".to_string()),
941            ),
942        ]);
943        let public = BTreeMap::from([
944            (
945                "pack".to_string(),
946                vec![PublicTermSql::Cmp {
947                    column: "published".to_string(),
948                    op: CmpOp::Eq,
949                    value: SqlValue::Boolean(true),
950                }],
951            ),
952            (
953                "products".to_string(),
954                vec![PublicTermSql::Cmp {
955                    column: "published".to_string(),
956                    op: CmpOp::Eq,
957                    value: SqlValue::Boolean(true),
958                }],
959            ),
960        ]);
961        // Plain `target` read (require_public = true, null_base = false) over a join of both tables.
962        let out = rewrite_target_select(
963            "SELECT p.id FROM pack p JOIN products x ON x.id = p.product_id",
964            &b(),
965            &keys,
966            &public,
967            true,
968            false,
969            Dialect::Sqlite,
970        )
971        .unwrap();
972        // `pack` (base-inclusive): (p.tenant_id = B OR p.tenant_id IS NULL) AND p.published.
973        assert!(
974            out.contains(
975                "(p.tenant_id = 'tenant_B' OR p.tenant_id IS NULL) AND p.published = true"
976            ),
977            "base-inclusive pack folds the NULL base: {out}"
978        );
979        // `products` (plain tenant): x.tenant_id = B alone — no base fold on the sibling table.
980        assert!(
981            out.contains("x.tenant_id = 'tenant_B' AND x.published = true")
982                && !out.contains("x.tenant_id IS NULL"),
983            "the plain tenant table stays tenant-only (per-table, not per-field): {out}"
984        );
985    }
986
987    #[test]
988    fn simple_select_is_confined() {
989        let out = rewrite("SELECT id FROM products").unwrap();
990        assert_eq!(
991            out,
992            "SELECT id FROM products WHERE products.tenant_id = 'tenant_B' AND products.published = true"
993        );
994    }
995
996    #[test]
997    fn capability_confines_tenant_only_when_no_subset_but_domain_handle_refuses() {
998        use std::collections::BTreeMap;
999        // `orders` is a plain tenant table with NO declared public subset.
1000        let keys = BTreeMap::from([(
1001            "orders".to_string(),
1002            ResolvedScope::Column("tenant_id".to_string()),
1003        )]);
1004        let public = BTreeMap::new();
1005        // capability-only (require_public = false): confine to `tenant = B` alone (no visibility
1006        // predicate) — the capability is the authorization; per-client stays in-guest.
1007        let out = rewrite_target_select(
1008            "SELECT id FROM orders WHERE total > 10",
1009            &b(),
1010            &keys,
1011            &public,
1012            false,
1013            false,
1014            Dialect::Sqlite,
1015        )
1016        .unwrap();
1017        assert_eq!(
1018            out,
1019            "SELECT id FROM orders WHERE (total > 10) AND orders.tenant_id = 'tenant_B'"
1020        );
1021        // domain/handle (require_public = true): the SAME table is refused — an anonymous actor needs
1022        // the visibility predicate as its only guard.
1023        let err = rewrite_target_select(
1024            "SELECT id FROM orders",
1025            &b(),
1026            &keys,
1027            &public,
1028            true,
1029            false,
1030            Dialect::Sqlite,
1031        )
1032        .unwrap_err();
1033        assert!(
1034            matches!(err, TargetRewriteError::PublicSubsetUndeclared(ref t) if t == "orders"),
1035            "{err:?}"
1036        );
1037        // Ruling A COMPLETE: a table that DOES declare a subset is confined to `tenant = B` ALONE
1038        // under a capability — the declared subset (authored for the anonymous funnel) is NOT applied.
1039        let out = rewrite_cap("SELECT id FROM products").unwrap();
1040        assert_eq!(
1041            out,
1042            "SELECT id FROM products WHERE products.tenant_id = 'tenant_B'"
1043        );
1044        assert!(
1045            !out.contains("published"),
1046            "declared subset must be inert on the capability axis: {out}"
1047        );
1048    }
1049
1050    #[test]
1051    fn capability_drops_declared_subset_so_a_resolver_filter_on_that_column_survives() {
1052        // The construens embed bug: `products` declares `published = true` for the anonymous funnel,
1053        // but a capability read filters on that same column for its own within-tenant purpose. The
1054        // declared subset must NOT be AND-ed on (it would collide), and the guest's own predicate is
1055        // preserved verbatim inside its parenthesised group — confined only by `tenant = B`.
1056        let out = rewrite_cap("SELECT id FROM products WHERE published = false").unwrap();
1057        assert_eq!(
1058            out,
1059            "SELECT id FROM products WHERE (published = false) AND products.tenant_id = 'tenant_B'"
1060        );
1061        // Every joined table that declares a subset is ALSO confined to `tenant = B` alone (the
1062        // reviews subset `visible = true` is not applied), so a capability read across a join is not
1063        // silently emptied by a funnel subset on the joined table.
1064        let out = rewrite_cap("SELECT p.id FROM products p JOIN reviews r ON r.product_id = p.id")
1065            .unwrap();
1066        assert!(out.contains("p.tenant_id = 'tenant_B'"), "{out}");
1067        assert!(out.contains("r.tenant_id = 'tenant_B'"), "{out}");
1068        assert!(
1069            !out.contains("published") && !out.contains("visible"),
1070            "no declared subset on either table under a capability: {out}"
1071        );
1072    }
1073
1074    #[test]
1075    fn existing_where_is_parenthesised_so_a_top_level_or_cannot_escape() {
1076        // The classic M2 escape: `WHERE 1=1 OR <anything>`. The guest predicate is parenthesised and
1077        // the confinement AND-ed on, so it can never widen past `tenant = B AND published`.
1078        let out = rewrite("SELECT id FROM products WHERE price < 10 OR 1 = 1").unwrap();
1079        assert_eq!(
1080            out,
1081            "SELECT id FROM products WHERE (price < 10 OR 1 = 1) AND products.tenant_id = 'tenant_B' AND products.published = true"
1082        );
1083    }
1084
1085    #[test]
1086    fn every_join_is_confined() {
1087        let out = rewrite(
1088            "SELECT p.id FROM products p JOIN reviews r ON r.product_id = p.id WHERE p.price < 10",
1089        )
1090        .unwrap();
1091        // BOTH the aliased root and the aliased join are confined on their own alias.
1092        assert!(
1093            out.contains("p.tenant_id = 'tenant_B' AND p.published = true"),
1094            "{out}"
1095        );
1096        assert!(
1097            out.contains("r.tenant_id = 'tenant_B' AND r.visible = true"),
1098            "{out}"
1099        );
1100    }
1101
1102    #[test]
1103    fn left_join_confines_the_joined_table_in_its_own_on_not_the_where() {
1104        // A LEFT JOIN's joined table is confined in its OWN `ON` (so an unmatched / other-tenant row
1105        // becomes NULL, never dropping the driving row or bleeding) — the driving table stays in the
1106        // WHERE. Confining the joined table in the WHERE would collapse the LEFT JOIN to an INNER
1107        // JOIN (dropping a no-config driving row so a SELECT-list COALESCE fallback never fires).
1108        let out = rewrite(
1109            "SELECT p.id, COALESCE(r.body, p.fallback) FROM products p \
1110             LEFT JOIN reviews r ON r.product_id = p.id",
1111        )
1112        .unwrap();
1113        // The joined `reviews` confinement is in the ON (parenthesising the guest ON), AND-ed on:
1114        assert!(
1115            out.contains(
1116                "LEFT JOIN reviews AS r ON (r.product_id = p.id) AND r.tenant_id = 'tenant_B' AND r.visible = true"
1117            ),
1118            "joined table confined in the ON: {out}"
1119        );
1120        // ...the driving `products` is in the WHERE, and `reviews` is NOT confined in the WHERE.
1121        assert!(
1122            out.contains("WHERE p.tenant_id = 'tenant_B' AND p.published = true"),
1123            "driving table confined in the WHERE: {out}"
1124        );
1125        assert!(
1126            !out.contains("WHERE p.tenant_id = 'tenant_B' AND p.published = true AND r."),
1127            "the LEFT-joined table must NOT be in the WHERE (would collapse to INNER): {out}"
1128        );
1129    }
1130
1131    #[test]
1132    fn a_left_join_with_a_top_level_or_in_its_on_cannot_escape_the_gate() {
1133        // The guest's ON is parenthesised before the confinement is AND-ed, so a top-level OR in it
1134        // can never widen past `tenant = B AND <public>` (the join-ON analog of the M2 WHERE closure).
1135        let out = rewrite(
1136            "SELECT p.id FROM products p LEFT JOIN reviews r ON r.product_id = p.id OR 1 = 1",
1137        )
1138        .unwrap();
1139        assert!(
1140            out.contains(
1141                "ON (r.product_id = p.id OR 1 = 1) AND r.tenant_id = 'tenant_B' AND r.visible = true"
1142            ),
1143            "the guest ON's top-level OR is parenthesised inside the gate: {out}"
1144        );
1145    }
1146
1147    #[test]
1148    fn a_right_or_full_outer_join_is_refused() {
1149        // RIGHT/FULL OUTER (nullable driving side) can't be soundly confined in the WHERE or a single
1150        // join ON — refused fail-closed (the same read is a LEFT … ON join).
1151        for sql in [
1152            "SELECT p.id FROM products p RIGHT JOIN reviews r ON r.product_id = p.id",
1153            "SELECT p.id FROM products p FULL OUTER JOIN reviews r ON r.product_id = p.id",
1154        ] {
1155            assert!(
1156                matches!(
1157                    rewrite(sql).unwrap_err(),
1158                    TargetRewriteError::UnsupportedJoin(_)
1159                ),
1160                "must refuse: {sql}"
1161            );
1162        }
1163    }
1164
1165    #[test]
1166    fn subquery_in_where_is_confined() {
1167        let out = rewrite(
1168            "SELECT id FROM products WHERE id IN (SELECT product_id FROM reviews WHERE visible = true)",
1169        )
1170        .unwrap();
1171        // The outer products ref is confined...
1172        assert!(out.contains("products.tenant_id = 'tenant_B'"), "{out}");
1173        // ...and the inner reviews subquery is independently confined.
1174        assert!(
1175            out.contains("reviews.tenant_id = 'tenant_B' AND reviews.visible = true"),
1176            "{out}"
1177        );
1178    }
1179
1180    #[test]
1181    fn any_cte_is_refused() {
1182        // CTEs are refused deny-by-default (a self-named CTE is a scope-shadowing leak vector; the
1183        // same read is expressible with a subquery/derived table, which is confined).
1184        assert_eq!(
1185            rewrite("WITH live AS (SELECT id FROM products) SELECT * FROM live WHERE id > 0")
1186                .unwrap_err(),
1187            TargetRewriteError::CteNotAllowed
1188        );
1189    }
1190
1191    /// Regression for the Critical review finding: a self-named CTE must NOT pass through unconfined.
1192    /// These exact statements previously leaked another tenant's private rows (the outer ref and the
1193    /// CTE body's own base ref were both skipped by the over-approximating scope). They must now be
1194    /// refused, never rewritten to a pass-through.
1195    #[test]
1196    fn self_named_cte_bypass_is_refused() {
1197        for hostile in [
1198            "WITH products AS (SELECT * FROM products WHERE tenant_id = 'tenant_A' AND published = false) SELECT * FROM products",
1199            "WITH reviews AS (SELECT * FROM reviews) SELECT id FROM products",
1200            "WITH secrets AS (SELECT * FROM secrets) SELECT * FROM secrets",
1201            "WITH RECURSIVE products AS (SELECT * FROM products) SELECT * FROM products",
1202        ] {
1203            assert_eq!(
1204                rewrite(hostile).unwrap_err(),
1205                TargetRewriteError::CteNotAllowed,
1206                "must refuse (never pass through unconfined): {hostile}"
1207            );
1208        }
1209    }
1210
1211    #[test]
1212    fn set_operation_arms_are_each_confined() {
1213        let out = rewrite("SELECT id FROM products UNION SELECT id FROM reviews").unwrap();
1214        assert!(
1215            out.contains("FROM products WHERE products.tenant_id = 'tenant_B'"),
1216            "{out}"
1217        );
1218        assert!(
1219            out.contains("FROM reviews WHERE reviews.tenant_id = 'tenant_B'"),
1220            "{out}"
1221        );
1222    }
1223
1224    #[test]
1225    fn unscoped_table_needs_a_public_subset_and_is_refused_without_one() {
1226        // `countries` is Unscoped but declares no public subset → deny-by-default.
1227        let err = rewrite("SELECT * FROM countries").unwrap_err();
1228        assert_eq!(
1229            err,
1230            TargetRewriteError::PublicSubsetUndeclared("countries".to_string())
1231        );
1232    }
1233
1234    #[test]
1235    fn undeclared_table_is_refused() {
1236        let err = rewrite("SELECT * FROM secrets").unwrap_err();
1237        assert_eq!(
1238            err,
1239            TargetRewriteError::PublicSubsetUndeclared("secrets".to_string())
1240        );
1241    }
1242
1243    #[test]
1244    fn a_write_is_refused() {
1245        assert_eq!(
1246            rewrite("DELETE FROM products WHERE id = 1").unwrap_err(),
1247            TargetRewriteError::NotReadOnly
1248        );
1249        assert_eq!(
1250            rewrite("UPDATE products SET published = false").unwrap_err(),
1251            TargetRewriteError::NotReadOnly
1252        );
1253        assert_eq!(
1254            rewrite("INSERT INTO products (id) VALUES (1)").unwrap_err(),
1255            TargetRewriteError::NotReadOnly
1256        );
1257    }
1258
1259    #[test]
1260    fn multiple_statements_are_refused() {
1261        assert_eq!(
1262            rewrite("SELECT id FROM products; SELECT id FROM reviews").unwrap_err(),
1263            TargetRewriteError::NotReadOnly
1264        );
1265    }
1266
1267    #[test]
1268    fn select_into_is_refused_as_a_write() {
1269        // `SELECT … INTO t` materialises a table — a write smuggled into a read.
1270        let err = rewrite("SELECT id INTO stash FROM products").unwrap_err();
1271        assert_eq!(err, TargetRewriteError::WriteInReadPosition);
1272    }
1273
1274    #[test]
1275    fn schema_qualified_table_name_is_refused() {
1276        let err = rewrite("SELECT id FROM public.products").unwrap_err();
1277        assert_eq!(
1278            err,
1279            TargetRewriteError::QualifiedTableName("public.products".to_string())
1280        );
1281    }
1282
1283    #[test]
1284    fn table_valued_function_is_refused() {
1285        // A TVF is not a confinable base table.
1286        let err = rewrite_target_select(
1287            "SELECT * FROM generate_series(1, 10)",
1288            &b(),
1289            &keys(),
1290            &public(),
1291            true,
1292            false,
1293            Dialect::Postgres,
1294        )
1295        .unwrap_err();
1296        assert!(
1297            matches!(err, TargetRewriteError::UnsupportedTableSource(_)),
1298            "{err:?}"
1299        );
1300    }
1301
1302    #[test]
1303    fn a_text_tenant_value_with_a_quote_is_escaped_not_injected() {
1304        // A hostile-looking B is host-derived and can't actually occur, but prove the literal is
1305        // escaped (doubled quote) rather than breaking out of the string.
1306        let out = rewrite_target_select(
1307            "SELECT id FROM products",
1308            &SqlValue::Text("x' OR '1'='1".to_string()),
1309            &keys(),
1310            &public(),
1311            true,
1312            false,
1313            Dialect::Sqlite,
1314        )
1315        .unwrap();
1316        assert!(
1317            out.contains("products.tenant_id = 'x'' OR ''1''=''1'"),
1318            "{out}"
1319        );
1320    }
1321
1322    #[test]
1323    fn nested_join_inner_tables_are_confined() {
1324        let out =
1325            rewrite("SELECT * FROM (products p JOIN reviews r ON r.product_id = p.id)").unwrap();
1326        assert!(out.contains("p.tenant_id = 'tenant_B'"), "{out}");
1327        assert!(out.contains("r.tenant_id = 'tenant_B'"), "{out}");
1328    }
1329
1330    #[test]
1331    fn derived_table_subquery_is_confined() {
1332        let out = rewrite("SELECT * FROM (SELECT id FROM products) AS live WHERE id > 0").unwrap();
1333        // The derived subquery confines `products`, and `live` is not a base table.
1334        assert!(
1335            out.contains("FROM products WHERE products.tenant_id = 'tenant_B'"),
1336            "{out}"
1337        );
1338        assert!(!out.contains("live.tenant_id"), "{out}");
1339    }
1340
1341    #[test]
1342    fn correlated_exists_subquery_is_confined() {
1343        let out = rewrite(
1344            "SELECT id FROM products WHERE EXISTS (SELECT 1 FROM reviews WHERE reviews.product_id = products.id)",
1345        )
1346        .unwrap();
1347        assert!(out.contains("products.tenant_id = 'tenant_B'"), "{out}");
1348        assert!(out.contains("reviews.tenant_id = 'tenant_B'"), "{out}");
1349    }
1350}