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}