use std::collections::BTreeMap;
use std::ops::ControlFlow;
use sqlparser::ast::{
BinaryOperator, Expr, Ident, Query, Select, SetExpr, Statement, TableFactor, TableWithJoins,
Value, VisitMut, VisitorMut,
};
use sqlparser::dialect::{Dialect as SpDialect, MySqlDialect, PostgreSqlDialect, SQLiteDialect};
use sqlparser::parser::Parser;
use crate::orm::{CmpOp, PublicTermSql};
use crate::sql::{Dialect, SqlValue};
use crate::tenancy::ResolvedScope;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TargetRewriteError {
Parse(String),
NotReadOnly,
WriteInReadPosition,
UnsupportedTableSource(String),
QualifiedTableName(String),
PublicSubsetUndeclared(String),
TenancyUndeclared(String),
UnsupportedLiteral,
BadColumn(String),
EmptyConfinement(String),
MissingTarget,
CteNotAllowed,
}
impl TargetRewriteError {
pub fn reason(&self) -> String {
match self {
Self::Parse(m) => format!("tenancy(target): raw SQL did not parse: {m}"),
Self::NotReadOnly => {
"tenancy(target): a target read must be a single read-only SELECT".into()
}
Self::WriteInReadPosition => {
"tenancy(target): a write is not allowed in a target read".into()
}
Self::UnsupportedTableSource(s) => {
format!("tenancy(target): unsupported table source in a target read: {s}")
}
Self::QualifiedTableName(t) => format!(
"tenancy(target): schema-qualified table name `{t}` is not allowed in a target \
read (use a bare table name)"
),
Self::PublicSubsetUndeclared(t) => format!(
"tenancy(target): table `{t}` declares no public subset (a target read may only \
reach tables with a declared public subset)"
),
Self::TenancyUndeclared(t) => {
format!("tenancy(target): table `{t}` is not declared in the tenancy schema")
}
Self::UnsupportedLiteral => {
"tenancy(target): a confinement literal cannot be safely rendered".into()
}
Self::BadColumn(c) => format!("tenancy(target): misconfigured column `{c}`"),
Self::EmptyConfinement(t) => {
format!("tenancy(target): table `{t}` lowered to an empty confinement")
}
Self::MissingTarget => {
"tenancy(target): no resolved target tenant for this request".into()
}
Self::CteNotAllowed => {
"tenancy(target): a WITH/CTE is not allowed in a target read (use a subquery or \
derived table)"
.into()
}
}
}
}
#[allow(clippy::too_many_arguments)] pub fn rewrite_target_select(
statement: &str,
tenant_value: &SqlValue,
keys: &BTreeMap<String, ResolvedScope>,
public: &BTreeMap<String, Vec<PublicTermSql>>,
require_public: bool,
null_base: bool,
dialect: Dialect,
) -> Result<String, TargetRewriteError> {
let sp: Box<dyn SpDialect> = match dialect {
Dialect::Sqlite => Box::new(SQLiteDialect {}),
Dialect::Postgres => Box::new(PostgreSqlDialect {}),
Dialect::Mysql => Box::new(MySqlDialect {}),
};
let mut statements =
Parser::parse_sql(&*sp, statement).map_err(|e| TargetRewriteError::Parse(e.to_string()))?;
if statements.len() != 1 {
return Err(TargetRewriteError::NotReadOnly);
}
match &statements[0] {
Statement::Query(_) => {}
_ => return Err(TargetRewriteError::NotReadOnly),
}
let bound = value_expr(tenant_value)?;
let mut rewriter = Rewriter {
keys,
public,
require_public,
bound,
null_base,
};
if let ControlFlow::Break(err) = statements[0].visit(&mut rewriter) {
return Err(err);
}
Ok(statements[0].to_string())
}
struct Rewriter<'a> {
keys: &'a BTreeMap<String, ResolvedScope>,
public: &'a BTreeMap<String, Vec<PublicTermSql>>,
require_public: bool,
bound: Expr,
null_base: bool,
}
impl VisitorMut for Rewriter<'_> {
type Break = TargetRewriteError;
fn pre_visit_query(&mut self, query: &mut Query) -> ControlFlow<Self::Break> {
if query.with.is_some() {
return ControlFlow::Break(TargetRewriteError::CteNotAllowed);
}
if let Err(e) = self.confine_body(&mut query.body) {
return ControlFlow::Break(e);
}
ControlFlow::Continue(())
}
fn pre_visit_table_factor(
&mut self,
table_factor: &mut TableFactor,
) -> ControlFlow<Self::Break> {
match table_factor {
TableFactor::Table { args: Some(_), .. } => ControlFlow::Break(
TargetRewriteError::UnsupportedTableSource("table-valued function".into()),
),
TableFactor::Table { name, .. } if name.0.len() != 1 => ControlFlow::Break(
TargetRewriteError::QualifiedTableName(object_name_string(name)),
),
TableFactor::Table { .. }
| TableFactor::Derived { .. }
| TableFactor::NestedJoin { .. } => ControlFlow::Continue(()),
other => ControlFlow::Break(TargetRewriteError::UnsupportedTableSource(
table_factor_kind(other).into(),
)),
}
}
}
impl Rewriter<'_> {
fn confine_body(&self, body: &mut SetExpr) -> Result<(), TargetRewriteError> {
match body {
SetExpr::Select(select) => self.confine_select(select),
SetExpr::SetOperation { left, right, .. } => {
self.confine_body(left)?;
self.confine_body(right)
}
SetExpr::Query(_) | SetExpr::Values(_) => Ok(()),
SetExpr::Insert(_) | SetExpr::Update(_) | SetExpr::Table(_) => {
Err(TargetRewriteError::WriteInReadPosition)
}
}
}
fn confine_select(&self, select: &mut Select) -> Result<(), TargetRewriteError> {
if select.into.is_some() {
return Err(TargetRewriteError::WriteInReadPosition);
}
let mut bases: Vec<(Ident, String)> = Vec::new();
for twj in &select.from {
self.collect_bases(twj, &mut bases)?;
}
if bases.is_empty() {
return Ok(());
}
let mut confinement: Option<Expr> = None;
for (qualifier, table) in &bases {
let Some(pred) = self.table_confinement(table, qualifier)? else {
continue;
};
confinement = Some(match confinement.take() {
Some(acc) => and(acc, pred),
None => pred,
});
}
let Some(confinement) = confinement else {
return Ok(());
};
select.selection = Some(match select.selection.take() {
Some(existing) => and(Expr::Nested(Box::new(existing)), confinement),
None => confinement,
});
Ok(())
}
fn collect_bases(
&self,
twj: &TableWithJoins,
out: &mut Vec<(Ident, String)>,
) -> Result<(), TargetRewriteError> {
self.collect_factor(&twj.relation, out)?;
for join in &twj.joins {
self.collect_factor(&join.relation, out)?;
}
Ok(())
}
fn collect_factor(
&self,
factor: &TableFactor,
out: &mut Vec<(Ident, String)>,
) -> Result<(), TargetRewriteError> {
match factor {
TableFactor::Table { args: Some(_), .. } => Err(
TargetRewriteError::UnsupportedTableSource("table-valued function".into()),
),
TableFactor::Table { name, alias, .. } => {
if name.0.len() != 1 {
return Err(TargetRewriteError::QualifiedTableName(object_name_string(
name,
)));
}
let base = name.0[0].value.clone();
let qualifier = alias
.as_ref()
.map(|a| a.name.clone())
.unwrap_or_else(|| name.0[0].clone());
out.push((qualifier, base));
Ok(())
}
TableFactor::Derived { .. } => Ok(()),
TableFactor::NestedJoin {
table_with_joins, ..
} => self.collect_bases(table_with_joins, out),
other => Err(TargetRewriteError::UnsupportedTableSource(
table_factor_kind(other).into(),
)),
}
}
fn table_confinement(
&self,
table: &str,
qualifier: &Ident,
) -> Result<Option<Expr>, TargetRewriteError> {
let empty: Vec<PublicTermSql> = Vec::new();
let terms = match self.public.get(table) {
Some(t) => t,
None if !self.require_public => &empty,
None => {
return Err(TargetRewriteError::PublicSubsetUndeclared(
table.to_string(),
))
}
};
let resolved = self
.keys
.get(table)
.ok_or_else(|| TargetRewriteError::TenancyUndeclared(table.to_string()))?;
let mut parts: Vec<Expr> = Vec::new();
match resolved {
ResolvedScope::Column(col) => {
check_ident(col)?;
let eq = binop(
col_expr(qualifier, col),
BinaryOperator::Eq,
self.bound.clone(),
);
parts.push(if self.null_base {
Expr::Nested(Box::new(or(
eq,
Expr::IsNull(Box::new(col_expr(qualifier, col))),
)))
} else {
eq
});
}
ResolvedScope::Unscoped => {}
ResolvedScope::TenantOrSession { tenant, .. } => {
check_ident(tenant)?;
parts.push(binop(
col_expr(qualifier, tenant),
BinaryOperator::Eq,
self.bound.clone(),
));
}
}
for term in terms {
match term {
PublicTermSql::Cmp { column, op, value } => {
check_ident(column)?;
parts.push(binop(
col_expr(qualifier, column),
cmp_operator(*op),
value_expr(value)?,
));
}
PublicTermSql::Null { column, negated } => {
check_ident(column)?;
let e = Box::new(col_expr(qualifier, column));
parts.push(if *negated {
Expr::IsNotNull(e)
} else {
Expr::IsNull(e)
});
}
}
}
let mut it = parts.into_iter();
let Some(first) = it.next() else {
return Ok(None);
};
Ok(Some(it.fold(first, and)))
}
}
fn col_expr(qualifier: &Ident, column: &str) -> Expr {
Expr::CompoundIdentifier(vec![qualifier.clone(), Ident::new(column)])
}
fn binop(left: Expr, op: BinaryOperator, right: Expr) -> Expr {
Expr::BinaryOp {
left: Box::new(left),
op,
right: Box::new(right),
}
}
fn and(left: Expr, right: Expr) -> Expr {
binop(left, BinaryOperator::And, right)
}
fn or(left: Expr, right: Expr) -> Expr {
binop(left, BinaryOperator::Or, right)
}
fn cmp_operator(op: CmpOp) -> BinaryOperator {
match op {
CmpOp::Eq => BinaryOperator::Eq,
CmpOp::Ne => BinaryOperator::NotEq,
CmpOp::Lt => BinaryOperator::Lt,
CmpOp::Le => BinaryOperator::LtEq,
CmpOp::Gt => BinaryOperator::Gt,
CmpOp::Ge => BinaryOperator::GtEq,
}
}
fn value_expr(value: &SqlValue) -> Result<Expr, TargetRewriteError> {
Ok(Expr::Value(match value {
SqlValue::Text(s) => Value::SingleQuotedString(s.clone()),
SqlValue::Integer(i) => Value::Number(i.to_string(), false),
SqlValue::Boolean(b) => Value::Boolean(*b),
SqlValue::Real(f) if f.is_finite() => Value::Number(f.to_string(), false),
SqlValue::Real(_) | SqlValue::Null | SqlValue::Blob(_) | SqlValue::Json(_) => {
return Err(TargetRewriteError::UnsupportedLiteral)
}
}))
}
fn check_ident(s: &str) -> Result<(), TargetRewriteError> {
let mut chars = s.chars();
let ok = matches!(chars.next(), Some(c) if c.is_ascii_alphabetic() || c == '_')
&& chars.all(|c| c.is_ascii_alphanumeric() || c == '_');
if ok {
Ok(())
} else {
Err(TargetRewriteError::BadColumn(s.to_string()))
}
}
fn object_name_string(name: &sqlparser::ast::ObjectName) -> String {
name.0
.iter()
.map(|i| i.value.clone())
.collect::<Vec<_>>()
.join(".")
}
fn table_factor_kind(factor: &TableFactor) -> &'static str {
match factor {
TableFactor::Table { .. } => "table",
TableFactor::Derived { .. } => "derived subquery",
TableFactor::TableFunction { .. } => "table function",
TableFactor::Function { .. } => "function",
TableFactor::UNNEST { .. } => "UNNEST",
TableFactor::JsonTable { .. } => "JSON_TABLE",
TableFactor::OpenJsonTable { .. } => "OPENJSON",
TableFactor::NestedJoin { .. } => "nested join",
TableFactor::Pivot { .. } => "PIVOT",
TableFactor::Unpivot { .. } => "UNPIVOT",
TableFactor::MatchRecognize { .. } => "MATCH_RECOGNIZE",
}
}
#[cfg(test)]
mod tests {
use super::*;
fn keys() -> BTreeMap<String, ResolvedScope> {
BTreeMap::from([
(
"products".to_string(),
ResolvedScope::Column("tenant_id".to_string()),
),
(
"reviews".to_string(),
ResolvedScope::Column("tenant_id".to_string()),
),
("countries".to_string(), ResolvedScope::Unscoped),
])
}
fn public() -> BTreeMap<String, Vec<PublicTermSql>> {
BTreeMap::from([
(
"products".to_string(),
vec![PublicTermSql::Cmp {
column: "published".to_string(),
op: CmpOp::Eq,
value: SqlValue::Boolean(true),
}],
),
(
"reviews".to_string(),
vec![PublicTermSql::Cmp {
column: "visible".to_string(),
op: CmpOp::Eq,
value: SqlValue::Boolean(true),
}],
),
])
}
fn b() -> SqlValue {
SqlValue::Text("tenant_B".to_string())
}
fn rewrite(sql: &str) -> Result<String, TargetRewriteError> {
rewrite_target_select(sql, &b(), &keys(), &public(), true, false, Dialect::Sqlite)
}
fn rewrite_cap(sql: &str) -> Result<String, TargetRewriteError> {
rewrite_target_select(sql, &b(), &keys(), &public(), false, false, Dialect::Sqlite)
}
fn rewrite_null_base(sql: &str) -> Result<String, TargetRewriteError> {
rewrite_target_select(sql, &b(), &keys(), &public(), true, true, Dialect::Sqlite)
}
#[test]
fn target_or_null_widens_a_tenant_table_to_include_the_null_base() {
let out = rewrite_null_base("SELECT id FROM products").unwrap();
assert_eq!(
out,
"SELECT id FROM products WHERE (products.tenant_id = 'tenant_B' OR products.tenant_id IS NULL) AND products.published = true"
);
assert_eq!(
rewrite("SELECT id FROM products").unwrap(),
"SELECT id FROM products WHERE products.tenant_id = 'tenant_B' AND products.published = true"
);
}
#[test]
fn simple_select_is_confined() {
let out = rewrite("SELECT id FROM products").unwrap();
assert_eq!(
out,
"SELECT id FROM products WHERE products.tenant_id = 'tenant_B' AND products.published = true"
);
}
#[test]
fn capability_confines_tenant_only_when_no_subset_but_domain_handle_refuses() {
use std::collections::BTreeMap;
let keys = BTreeMap::from([(
"orders".to_string(),
ResolvedScope::Column("tenant_id".to_string()),
)]);
let public = BTreeMap::new();
let out = rewrite_target_select(
"SELECT id FROM orders WHERE total > 10",
&b(),
&keys,
&public,
false,
false,
Dialect::Sqlite,
)
.unwrap();
assert_eq!(
out,
"SELECT id FROM orders WHERE (total > 10) AND orders.tenant_id = 'tenant_B'"
);
let err = rewrite_target_select(
"SELECT id FROM orders",
&b(),
&keys,
&public,
true,
false,
Dialect::Sqlite,
)
.unwrap_err();
assert!(
matches!(err, TargetRewriteError::PublicSubsetUndeclared(ref t) if t == "orders"),
"{err:?}"
);
let out = rewrite_cap("SELECT id FROM products").unwrap();
assert!(
out.contains("products.tenant_id = 'tenant_B' AND products.published = true"),
"{out}"
);
}
#[test]
fn existing_where_is_parenthesised_so_a_top_level_or_cannot_escape() {
let out = rewrite("SELECT id FROM products WHERE price < 10 OR 1 = 1").unwrap();
assert_eq!(
out,
"SELECT id FROM products WHERE (price < 10 OR 1 = 1) AND products.tenant_id = 'tenant_B' AND products.published = true"
);
}
#[test]
fn every_join_is_confined() {
let out = rewrite(
"SELECT p.id FROM products p JOIN reviews r ON r.product_id = p.id WHERE p.price < 10",
)
.unwrap();
assert!(
out.contains("p.tenant_id = 'tenant_B' AND p.published = true"),
"{out}"
);
assert!(
out.contains("r.tenant_id = 'tenant_B' AND r.visible = true"),
"{out}"
);
}
#[test]
fn subquery_in_where_is_confined() {
let out = rewrite(
"SELECT id FROM products WHERE id IN (SELECT product_id FROM reviews WHERE visible = true)",
)
.unwrap();
assert!(out.contains("products.tenant_id = 'tenant_B'"), "{out}");
assert!(
out.contains("reviews.tenant_id = 'tenant_B' AND reviews.visible = true"),
"{out}"
);
}
#[test]
fn any_cte_is_refused() {
assert_eq!(
rewrite("WITH live AS (SELECT id FROM products) SELECT * FROM live WHERE id > 0")
.unwrap_err(),
TargetRewriteError::CteNotAllowed
);
}
#[test]
fn self_named_cte_bypass_is_refused() {
for hostile in [
"WITH products AS (SELECT * FROM products WHERE tenant_id = 'tenant_A' AND published = false) SELECT * FROM products",
"WITH reviews AS (SELECT * FROM reviews) SELECT id FROM products",
"WITH secrets AS (SELECT * FROM secrets) SELECT * FROM secrets",
"WITH RECURSIVE products AS (SELECT * FROM products) SELECT * FROM products",
] {
assert_eq!(
rewrite(hostile).unwrap_err(),
TargetRewriteError::CteNotAllowed,
"must refuse (never pass through unconfined): {hostile}"
);
}
}
#[test]
fn set_operation_arms_are_each_confined() {
let out = rewrite("SELECT id FROM products UNION SELECT id FROM reviews").unwrap();
assert!(
out.contains("FROM products WHERE products.tenant_id = 'tenant_B'"),
"{out}"
);
assert!(
out.contains("FROM reviews WHERE reviews.tenant_id = 'tenant_B'"),
"{out}"
);
}
#[test]
fn unscoped_table_needs_a_public_subset_and_is_refused_without_one() {
let err = rewrite("SELECT * FROM countries").unwrap_err();
assert_eq!(
err,
TargetRewriteError::PublicSubsetUndeclared("countries".to_string())
);
}
#[test]
fn undeclared_table_is_refused() {
let err = rewrite("SELECT * FROM secrets").unwrap_err();
assert_eq!(
err,
TargetRewriteError::PublicSubsetUndeclared("secrets".to_string())
);
}
#[test]
fn a_write_is_refused() {
assert_eq!(
rewrite("DELETE FROM products WHERE id = 1").unwrap_err(),
TargetRewriteError::NotReadOnly
);
assert_eq!(
rewrite("UPDATE products SET published = false").unwrap_err(),
TargetRewriteError::NotReadOnly
);
assert_eq!(
rewrite("INSERT INTO products (id) VALUES (1)").unwrap_err(),
TargetRewriteError::NotReadOnly
);
}
#[test]
fn multiple_statements_are_refused() {
assert_eq!(
rewrite("SELECT id FROM products; SELECT id FROM reviews").unwrap_err(),
TargetRewriteError::NotReadOnly
);
}
#[test]
fn select_into_is_refused_as_a_write() {
let err = rewrite("SELECT id INTO stash FROM products").unwrap_err();
assert_eq!(err, TargetRewriteError::WriteInReadPosition);
}
#[test]
fn schema_qualified_table_name_is_refused() {
let err = rewrite("SELECT id FROM public.products").unwrap_err();
assert_eq!(
err,
TargetRewriteError::QualifiedTableName("public.products".to_string())
);
}
#[test]
fn table_valued_function_is_refused() {
let err = rewrite_target_select(
"SELECT * FROM generate_series(1, 10)",
&b(),
&keys(),
&public(),
true,
false,
Dialect::Postgres,
)
.unwrap_err();
assert!(
matches!(err, TargetRewriteError::UnsupportedTableSource(_)),
"{err:?}"
);
}
#[test]
fn a_text_tenant_value_with_a_quote_is_escaped_not_injected() {
let out = rewrite_target_select(
"SELECT id FROM products",
&SqlValue::Text("x' OR '1'='1".to_string()),
&keys(),
&public(),
true,
false,
Dialect::Sqlite,
)
.unwrap();
assert!(
out.contains("products.tenant_id = 'x'' OR ''1''=''1'"),
"{out}"
);
}
#[test]
fn nested_join_inner_tables_are_confined() {
let out =
rewrite("SELECT * FROM (products p JOIN reviews r ON r.product_id = p.id)").unwrap();
assert!(out.contains("p.tenant_id = 'tenant_B'"), "{out}");
assert!(out.contains("r.tenant_id = 'tenant_B'"), "{out}");
}
#[test]
fn derived_table_subquery_is_confined() {
let out = rewrite("SELECT * FROM (SELECT id FROM products) AS live WHERE id > 0").unwrap();
assert!(
out.contains("FROM products WHERE products.tenant_id = 'tenant_B'"),
"{out}"
);
assert!(!out.contains("live.tenant_id"), "{out}");
}
#[test]
fn correlated_exists_subquery_is_confined() {
let out = rewrite(
"SELECT id FROM products WHERE EXISTS (SELECT 1 FROM reviews WHERE reviews.product_id = products.id)",
)
.unwrap();
assert!(out.contains("products.tenant_id = 'tenant_B'"), "{out}");
assert!(out.contains("reviews.tenant_id = 'tenant_B'"), "{out}");
}
}