Skip to main content

prax_query/tenant/
middleware.rs

1//! Tenant middleware for automatic query filtering.
2
3use super::config::TenantConfig;
4use super::context::TenantContext;
5use super::strategy::ColumnType;
6use super::task_local;
7use crate::error::{QueryError, QueryResult};
8use crate::middleware::{BoxFuture, Middleware, Next, QueryContext, QueryResponse, QueryType};
9use std::sync::{Arc, RwLock};
10
11/// Middleware that automatically applies tenant filtering to queries.
12pub struct TenantMiddleware {
13    config: TenantConfig,
14    current_tenant: Arc<RwLock<Option<TenantContext>>>,
15}
16
17impl TenantMiddleware {
18    /// Create a new tenant middleware with the given config.
19    pub fn new(config: TenantConfig) -> Self {
20        Self {
21            config,
22            current_tenant: Arc::new(RwLock::new(None)),
23        }
24    }
25
26    /// Set the current tenant context.
27    ///
28    /// This writes a process-wide slot shared by every clone of this
29    /// middleware, so concurrent requests would overwrite each other's
30    /// tenant. It is a single-threaded escape hatch — prefer
31    /// `task_local::with_tenant`, which this middleware resolves per query
32    /// before falling back to this slot.
33    pub fn set_tenant(&self, ctx: TenantContext) {
34        *self.current_tenant.write().expect("lock poisoned") = Some(ctx);
35    }
36
37    /// Clear the current tenant context.
38    pub fn clear_tenant(&self) {
39        *self.current_tenant.write().expect("lock poisoned") = None;
40    }
41
42    /// Get the current tenant context.
43    pub fn current_tenant(&self) -> Option<TenantContext> {
44        self.current_tenant.read().expect("lock poisoned").clone()
45    }
46
47    /// Create a scoped tenant context (automatically clears on drop).
48    pub fn scoped(&self, ctx: TenantContext) -> TenantScope {
49        self.set_tenant(ctx);
50        TenantScope {
51            middleware: Arc::new(self.clone()),
52        }
53    }
54
55    /// Apply row-level filtering to a SQL query.
56    fn apply_row_level_filter(&self, sql: &str, tenant_id: &str) -> QueryResult<String> {
57        let config = match self.config.row_level_config() {
58            Some(c) => c,
59            None => return Ok(sql.to_string()),
60        };
61
62        let tenant_value = validated_tenant_value(&config.column, config.column_type, tenant_id)?;
63
64        // Parse and modify SQL
65        self.inject_tenant_filter(sql, &config.column, &tenant_value)
66    }
67
68    /// Inject tenant filter into SQL.
69    fn inject_tenant_filter(&self, sql: &str, column: &str, value: &str) -> QueryResult<String> {
70        let filter = format!("{} = {}", column, value);
71        let body = sql.trim();
72        if body.is_empty() {
73            return Ok(sql.to_string());
74        }
75
76        match classify_statement(body) {
77            // SELECT / UPDATE / DELETE (including CTE-wrapped writes):
78            // inject the filter into the top-level WHERE clause.
79            shape @ (StatementShape::Select | StatementShape::Write) => {
80                // Tolerate a single trailing semicolon when rewriting.
81                let (body, semi) = match body.strip_suffix(';') {
82                    Some(b) => (b.trim_end(), ";"),
83                    None => (body, ""),
84                };
85                let rewritten = if shape == StatementShape::Select {
86                    inject_where_filter(
87                        body,
88                        &filter,
89                        &[
90                            "GROUP BY", "HAVING", "WINDOW", "ORDER BY", "LIMIT", "OFFSET", "FETCH",
91                        ],
92                        &["UNION", "INTERSECT", "EXCEPT", "FOR"],
93                    )?
94                } else {
95                    inject_where_filter(body, &filter, &["RETURNING"], &[])?
96                };
97                Ok(format!("{}{}", rewritten, semi))
98            }
99
100            // INSERT: add the tenant column/value when configured to do so.
101            StatementShape::Insert => {
102                if self
103                    .config
104                    .row_level_config()
105                    .is_some_and(|c| c.auto_insert)
106                {
107                    inject_insert_column(body, column, value)
108                } else {
109                    Ok(sql.to_string())
110                }
111            }
112
113            // WITH … SELECT: tenant-scoping a CTE requires rewriting inside
114            // subqueries — refuse loudly rather than filter incompletely.
115            StatementShape::WithSelect => Err(QueryError::invalid_input(
116                "sql",
117                "cannot safely apply a tenant filter to a CTE-wrapped SELECT statement",
118            )),
119
120            // Anything else (DDL, transactions, MERGE, REPLACE INTO, …):
121            // fail closed instead of passing through unfiltered.
122            StatementShape::Other => Err(QueryError::invalid_input(
123                "sql",
124                "cannot safely apply a tenant filter: unrecognized statement shape",
125            )),
126        }
127    }
128
129    /// Apply schema-based isolation.
130    fn apply_schema_isolation(&self, tenant_id: &str) -> Option<String> {
131        self.config
132            .schema_config()
133            .map(|c| c.search_path(tenant_id))
134    }
135}
136
137impl Clone for TenantMiddleware {
138    fn clone(&self) -> Self {
139        Self {
140            config: self.config.clone(),
141            current_tenant: Arc::clone(&self.current_tenant),
142        }
143    }
144}
145
146impl std::fmt::Debug for TenantMiddleware {
147    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
148        f.debug_struct("TenantMiddleware")
149            .field("config", &self.config)
150            .field("has_tenant", &self.current_tenant().is_some())
151            .finish()
152    }
153}
154
155impl Middleware for TenantMiddleware {
156    fn handle<'a>(
157        &'a self,
158        mut ctx: QueryContext,
159        next: Next<'a>,
160    ) -> BoxFuture<'a, QueryResult<QueryResponse>> {
161        Box::pin(async move {
162            // Resolve the tenant for this query: the task-local context (set
163            // per request via `task_local::with_tenant`) takes precedence; the
164            // middleware-wide slot is a backward-compatible fallback.
165            let tenant_ctx = match task_local::current_tenant().or_else(|| self.current_tenant()) {
166                Some(ctx) => ctx,
167                None => {
168                    // No tenant context
169                    if self.config.require_tenant {
170                        if let Some(default) = &self.config.default_tenant {
171                            TenantContext::new(default.clone())
172                        } else {
173                            return Err(QueryError::internal(
174                                "Tenant context required but not provided",
175                            ));
176                        }
177                    } else {
178                        // No tenant filtering
179                        return next.run(ctx).await;
180                    }
181                }
182            };
183
184            // Check for bypass
185            if self.config.allow_bypass && tenant_ctx.should_bypass() {
186                if self.config.log_tenant_context {
187                    tracing::debug!(
188                        tenant_id = %tenant_ctx.id,
189                        bypass = true,
190                        "Tenant filter bypassed"
191                    );
192                }
193                return next.run(ctx).await;
194            }
195
196            // Apply row-level filtering if configured
197            if self.config.strategy.is_row_level() {
198                let query_type = ctx.query_type();
199
200                // Apply filter to query
201                let modified_sql =
202                    self.apply_row_level_filter(ctx.sql(), tenant_ctx.id.as_str())?;
203
204                // Update context with modified SQL
205                ctx = ctx.with_sql(modified_sql);
206
207                // Enforce tenant scoping on writes: with write validation
208                // enabled, an UPDATE/DELETE that still does not reference the
209                // tenant column after rewriting is rejected loudly instead of
210                // running unscoped. CTE-wrapped writes (`WITH … UPDATE/DELETE`)
211                // are classified as `Unknown` by `QueryType::from_sql`, so the
212                // rewritten statement is classified here as well to keep the
213                // guard firing for them.
214                let is_write = matches!(query_type, QueryType::Update | QueryType::Delete)
215                    || classify_statement(ctx.sql()) == StatementShape::Write;
216                if self.config.enforce_on_writes
217                    && is_write
218                    && let Some(row_level) = self.config.row_level_config()
219                    && row_level.validate_writes
220                    && !references_identifier(ctx.sql(), &row_level.column)
221                {
222                    return Err(QueryError::invalid_input(
223                        &row_level.column,
224                        "UPDATE/DELETE does not reference the tenant column",
225                    ));
226                }
227            }
228
229            // Apply schema-based isolation if configured
230            if self.config.strategy.is_schema_based()
231                && let Some(search_path) = self.apply_schema_isolation(tenant_ctx.id.as_str())
232            {
233                // The search_path should be set on the connection
234                // This is typically done by the connection manager
235                ctx.metadata_mut().set_schema_override(Some(
236                    self.config
237                        .schema_config()
238                        .unwrap()
239                        .schema_name(tenant_ctx.id.as_str()),
240                ));
241
242                // Log the schema setting
243                if self.config.log_tenant_context {
244                    tracing::debug!(
245                        tenant_id = %tenant_ctx.id,
246                        search_path = %search_path,
247                        "Setting schema for tenant"
248                    );
249                }
250            }
251
252            // Log tenant context
253            if self.config.log_tenant_context {
254                tracing::debug!(
255                    tenant_id = %tenant_ctx.id,
256                    strategy = ?self.config.strategy,
257                    sql = %ctx.sql(),
258                    "Executing query with tenant context"
259                );
260            }
261
262            // Set tenant in metadata for downstream middleware
263            ctx.metadata_mut().tenant_id = Some(tenant_ctx.id.to_string());
264
265            // Continue with modified query
266            next.run(ctx).await
267        })
268    }
269
270    fn name(&self) -> &'static str {
271        "TenantMiddleware"
272    }
273}
274
275// ============================================================================
276// SQL rewriting helpers
277// ============================================================================
278//
279// The middleware rewrites SQL textually — parameter binding is not possible
280// here without changing public signatures — so the tenant VALUE is always
281// validated against the declared column type and escaped (see
282// `validated_tenant_value`), and every rewrite below is conservative: a
283// statement whose shape is not recognized is rejected with an error instead
284// of being passed through silently or emitting invalid SQL.
285//
286// The scanner understands string literals ('...'), PostgreSQL dollar-quoted
287// strings ($$…$$ / $tag$…$tag$), quoted identifiers ("...", `...`, [...]),
288// line and (nested) block comments, and parenthesis depth. All byte offsets
289// index the ORIGINAL string; case-insensitive keyword matching is done
290// byte-wise so offsets never misalign on non-ASCII input (unlike matching
291// against an uppercased copy of the SQL).
292
293/// Validate `tenant_id` against the declared column type and return its safe
294/// SQL literal form. Delegates to [`ColumnType::try_format_value`] — the
295/// single source of truth for tenant value validation — and re-annotates the
296/// error with the tenant column and the offending value.
297fn validated_tenant_value(
298    column: &str,
299    column_type: ColumnType,
300    tenant_id: &str,
301) -> QueryResult<String> {
302    column_type.try_format_value(tenant_id).map_err(|_| {
303        let expectation = match column_type {
304            ColumnType::String => "a string tenant id matching ^[A-Za-z0-9_:-.@]+$",
305            ColumnType::Uuid => "a valid UUID",
306            ColumnType::Integer | ColumnType::BigInt => "a valid integer",
307        };
308        QueryError::invalid_input(
309            column,
310            format!("tenant id is not {expectation}: {tenant_id:?}"),
311        )
312    })
313}
314
315/// Inject `filter` into the WHERE clause of a SELECT/UPDATE/DELETE body.
316///
317/// `terminators` are the clause keywords that may follow the WHERE clause —
318/// the filter is inserted before the first of them (so a WHERE never lands
319/// after GROUP BY/HAVING). `forbidden` are keywords whose presence makes the
320/// statement unsafe to rewrite textually (compound SELECTs, locking reads);
321/// such statements are rejected.
322fn inject_where_filter(
323    sql: &str,
324    filter: &str,
325    terminators: &[&str],
326    forbidden: &[&str],
327) -> QueryResult<String> {
328    if find_top_level_keyword(sql, forbidden).is_some() {
329        return Err(QueryError::invalid_input(
330            "sql",
331            "cannot safely apply a tenant filter to a compound or locking statement",
332        ));
333    }
334
335    let where_hit = find_top_level_keyword(sql, &["WHERE"]);
336    let terminator_hit = find_top_level_keyword(sql, terminators);
337
338    match (where_hit, terminator_hit) {
339        // A following-clause keyword before the WHERE — the statement shape
340        // is not recognized, so reject rather than emit invalid SQL.
341        (Some((where_start, _)), Some((term_start, _))) if term_start < where_start => {
342            Err(QueryError::invalid_input(
343                "sql",
344                "cannot safely apply a tenant filter: unrecognized clause ordering",
345            ))
346        }
347        // Existing WHERE: `… WHERE <filter> AND (<existing>) <rest>`. The
348        // existing predicate is parenthesized so OR branches cannot bypass
349        // the tenant filter.
350        (Some((_, where_end)), terminator) => {
351            let close = terminator.map_or(sql.len(), |(start, _)| start);
352            let existing = sql[where_end..close].trim();
353            if existing.is_empty() {
354                return Err(QueryError::invalid_input(
355                    "sql",
356                    "cannot safely apply a tenant filter: empty WHERE clause",
357                ));
358            }
359            Ok(format!(
360                "{} {} AND ({}) {}",
361                sql[..where_end].trim_end(),
362                filter,
363                existing,
364                sql[close..].trim_start()
365            ))
366        }
367        // No WHERE yet: add one before the next clause (GROUP BY / …) …
368        (None, Some((term_start, _))) => Ok(format!(
369            "{} WHERE {} {}",
370            sql[..term_start].trim_end(),
371            filter,
372            sql[term_start..].trim_start()
373        )),
374        // … or at the end of the statement.
375        (None, None) => Ok(format!("{} WHERE {}", sql.trim_end(), filter)),
376    }
377}
378
379/// Add the tenant column/value to an INSERT of the shape
380/// `INSERT INTO <table> (<cols>) VALUES (<exprs>) <rest>`.
381///
382/// The parse is deliberately conservative: statements without an explicit
383/// column list, with a value/source other than a single `VALUES (...)` group,
384/// with multi-row VALUES, or with a column/expression count mismatch are
385/// rejected instead of passing through unscoped. If the column list already
386/// contains the tenant column, the supplied value expression must be exactly
387/// the current tenant's validated literal (after whitespace normalization) —
388/// otherwise one tenant could write rows owned by another, so a mismatch is
389/// rejected.
390fn inject_insert_column(sql: &str, column: &str, value: &str) -> QueryResult<String> {
391    let reject = || {
392        QueryError::invalid_input(
393            "sql",
394            "cannot safely auto-insert the tenant column into this INSERT statement",
395        )
396    };
397
398    let Some((_, into_end)) = find_top_level_keyword(sql, &["INTO"]) else {
399        return Err(reject());
400    };
401    let name_start = skip_ws(sql, into_end);
402    let Some(name_end) = parse_table_name(sql, name_start) else {
403        return Err(reject());
404    };
405
406    let cols_open = skip_ws(sql, name_end);
407    if sql.as_bytes().get(cols_open) != Some(&b'(') {
408        // No explicit column list (VALUES/DEFAULT VALUES/SELECT directly).
409        return Err(reject());
410    }
411    let Some(cols_close) = find_matching_paren(sql, cols_open) else {
412        return Err(reject());
413    };
414
415    let columns = split_top_level_commas(&sql[cols_open + 1..cols_close]);
416    if columns.iter().all(|c| c.trim().is_empty()) {
417        return Err(reject());
418    }
419    let tenant_column_index = columns
420        .iter()
421        .position(|c| unquote_ident(c.trim()).eq_ignore_ascii_case(column));
422
423    let after_cols = skip_ws(sql, cols_close + 1);
424    let Some((_, values_end)) = match_keyword_at(sql, after_cols, &["VALUES"]) else {
425        return Err(reject());
426    };
427    let vals_open = skip_ws(sql, values_end);
428    if sql.as_bytes().get(vals_open) != Some(&b'(') {
429        return Err(reject());
430    }
431    let Some(vals_close) = find_matching_paren(sql, vals_open) else {
432        return Err(reject());
433    };
434
435    let values = split_top_level_commas(&sql[vals_open + 1..vals_close]);
436    if values.len() != columns.len() {
437        return Err(reject());
438    }
439    // Multi-row VALUES — too fragile to rewrite safely.
440    let after_vals = skip_ws(sql, vals_close + 1);
441    if sql.as_bytes().get(after_vals) == Some(&b',') {
442        return Err(reject());
443    }
444
445    // The tenant column is already present: pass through only when the
446    // supplied value is exactly the current tenant's validated literal.
447    if let Some(index) = tenant_column_index {
448        if !sql_literal_eq(values[index], value) {
449            return Err(QueryError::invalid_input(
450                column,
451                "INSERT supplies a tenant column value that does not match the current tenant",
452            ));
453        }
454        return Ok(sql.to_string());
455    }
456
457    Ok(format!(
458        "{}, {}{}, {}{}",
459        &sql[..cols_close],
460        column,
461        &sql[cols_close..vals_close],
462        value,
463        &sql[vals_close..]
464    ))
465}
466
467/// Compare two SQL value expressions for exact equality after normalizing
468/// whitespace (leading/trailing trimmed, internal runs collapsed to one
469/// space).
470fn sql_literal_eq(a: &str, b: &str) -> bool {
471    a.split_ascii_whitespace().collect::<Vec<_>>().join(" ")
472        == b.split_ascii_whitespace().collect::<Vec<_>>().join(" ")
473}
474
475/// The shape of a statement, for tenant-filter dispatch.
476#[derive(Debug, Clone, Copy, PartialEq, Eq)]
477enum StatementShape {
478    /// `SELECT …`
479    Select,
480    /// `UPDATE`/`DELETE`, including CTE-wrapped writes
481    /// (`WITH … UPDATE/DELETE`), which are rewritten and guarded exactly
482    /// like plain writes.
483    Write,
484    /// `INSERT …`
485    Insert,
486    /// `WITH … SELECT` — rejected: tenant-scoping a CTE requires rewriting
487    /// inside subqueries, so it is refused loudly rather than filtered
488    /// incompletely.
489    WithSelect,
490    /// Anything else — rejected (fail closed).
491    Other,
492}
493
494/// Classify a statement for tenant-filter dispatch, skipping leading
495/// whitespace and line/block comments before the first keyword and looking
496/// through a `WITH …` prefix to the body keyword.
497fn classify_statement(sql: &str) -> StatementShape {
498    let pos = skip_ws(sql, 0);
499    if match_keyword_at(sql, pos, &["SELECT"]).is_some() {
500        StatementShape::Select
501    } else if match_keyword_at(sql, pos, &["UPDATE", "DELETE"]).is_some() {
502        StatementShape::Write
503    } else if match_keyword_at(sql, pos, &["INSERT"]).is_some() {
504        StatementShape::Insert
505    } else if let Some((_, with_end)) = match_keyword_at(sql, pos, &["WITH"]) {
506        match cte_body_offset(sql, with_end) {
507            Some(body) if match_keyword_at(sql, body, &["SELECT"]).is_some() => {
508                StatementShape::WithSelect
509            }
510            Some(body) if match_keyword_at(sql, body, &["UPDATE", "DELETE"]).is_some() => {
511                StatementShape::Write
512            }
513            _ => StatementShape::Other,
514        }
515    } else {
516        StatementShape::Other
517    }
518}
519
520/// Given `pos` just past the `WITH` keyword, parse the CTE list
521/// (`name [(columns)] AS (<subquery>)` entries separated by commas) and
522/// return the offset where the statement body starts. Returns `None` when
523/// the CTE list cannot be parsed conservatively.
524fn cte_body_offset(sql: &str, mut pos: usize) -> Option<usize> {
525    let bytes = sql.as_bytes();
526    // Optional RECURSIVE modifier.
527    pos = skip_ws(sql, pos);
528    if let Some((_, end)) = match_keyword_at(sql, pos, &["RECURSIVE"]) {
529        pos = end;
530    }
531    loop {
532        // CTE name: a bare or quoted identifier.
533        pos = skip_ws(sql, pos);
534        match bytes.get(pos) {
535            Some(b'"') | Some(b'`') | Some(b'[') => pos = skip_quoted(sql, pos),
536            Some(b) if b.is_ascii_alphabetic() || *b == b'_' => {
537                pos += 1;
538                while pos < bytes.len() && is_ident_byte(bytes[pos]) {
539                    pos += 1;
540                }
541            }
542            _ => return None,
543        }
544        pos = skip_ws(sql, pos);
545        // Optional column list.
546        if bytes.get(pos) == Some(&b'(') {
547            pos = find_matching_paren(sql, pos)? + 1;
548            pos = skip_ws(sql, pos);
549        }
550        // AS keyword, then the parenthesized subquery.
551        let (_, as_end) = match_keyword_at(sql, pos, &["AS"])?;
552        pos = skip_ws(sql, as_end);
553        if bytes.get(pos) != Some(&b'(') {
554            return None;
555        }
556        pos = find_matching_paren(sql, pos)? + 1;
557        pos = skip_ws(sql, pos);
558        match bytes.get(pos) {
559            Some(&b',') => pos += 1,
560            _ => return Some(pos),
561        }
562    }
563}
564
565/// Identifier characters for word-boundary checks.
566fn is_ident_byte(b: u8) -> bool {
567    b.is_ascii_alphanumeric() || b == b'_' || b == b'$'
568}
569
570/// If a line (`--`) or block (`/* */`) comment starts at `pos`, return the
571/// offset just past it. Block comments nest (PostgreSQL rules), so text
572/// after an inner `*/` but still inside the outer comment is skipped as
573/// comment. On dialects without nesting the worst case is extra skipped
574/// text, which surfaces as a database syntax error — fail closed.
575fn skip_comment(sql: &str, pos: usize) -> Option<usize> {
576    let bytes = sql.as_bytes();
577    if pos + 1 < bytes.len() && bytes[pos] == b'-' && bytes[pos + 1] == b'-' {
578        let mut i = pos + 2;
579        while i < bytes.len() && bytes[i] != b'\n' {
580            i += 1;
581        }
582        Some(i)
583    } else if pos + 1 < bytes.len() && bytes[pos] == b'/' && bytes[pos + 1] == b'*' {
584        let mut depth = 1usize;
585        let mut i = pos + 2;
586        while i + 1 < bytes.len() && depth > 0 {
587            if bytes[i] == b'/' && bytes[i + 1] == b'*' {
588                depth += 1;
589                i += 2;
590            } else if bytes[i] == b'*' && bytes[i + 1] == b'/' {
591                depth -= 1;
592                i += 2;
593            } else {
594                i += 1;
595            }
596        }
597        Some(i.min(bytes.len()))
598    } else {
599        None
600    }
601}
602
603/// Skip whitespace and comments starting at byte offset `pos`.
604fn skip_ws(sql: &str, mut pos: usize) -> usize {
605    let bytes = sql.as_bytes();
606    loop {
607        while pos < bytes.len() && bytes[pos].is_ascii_whitespace() {
608            pos += 1;
609        }
610        match skip_comment(sql, pos) {
611            Some(next) => pos = next,
612            None => return pos,
613        }
614    }
615}
616
617/// Return the offset just past the string literal / quoted identifier that
618/// opens at `pos` (`sql` must start with a quote byte there). Handles
619/// doubled-quote escapes (`''`, `""`, and `]]` — the last being exactly
620/// T-SQL's bracket escape). Returns `sql.len()` if unterminated.
621fn skip_quoted(sql: &str, pos: usize) -> usize {
622    let bytes = sql.as_bytes();
623    let close = match bytes[pos] {
624        b'[' => b']',
625        quote => quote,
626    };
627    let mut i = pos + 1;
628    while i < bytes.len() {
629        if bytes[i] == close {
630            if i + 1 < bytes.len() && bytes[i + 1] == close {
631                i += 2; // doubled-quote escape
632                continue;
633            }
634            return i + 1;
635        }
636        i += 1;
637    }
638    bytes.len()
639}
640
641/// If a PostgreSQL dollar-quoted string (`$$…$$`, `$tag$…$tag$`) opens at
642/// `pos`, return the offset just past its closing delimiter. Returns `None`
643/// when the `$` does not open a dollar quote (e.g. a `$1` parameter
644/// placeholder). Dollar quotes never nest and the closing tag must match
645/// exactly; an unterminated quote consumes to end of input, matching
646/// `skip_quoted`'s fail-at-the-database stance.
647fn skip_dollar_quoted(sql: &str, pos: usize) -> Option<usize> {
648    let bytes = sql.as_bytes();
649    if bytes.get(pos) != Some(&b'$') {
650        return None;
651    }
652    // The tag follows unquoted-identifier rules (no leading digit), so a
653    // digit right after `$` means a parameter placeholder, not a quote.
654    if bytes.get(pos + 1).is_some_and(u8::is_ascii_digit) {
655        return None;
656    }
657    let mut i = pos + 1;
658    while i < bytes.len() && (bytes[i].is_ascii_alphanumeric() || bytes[i] == b'_') {
659        i += 1;
660    }
661    if bytes.get(i) != Some(&b'$') {
662        return None;
663    }
664    let tag = &sql[pos..=i];
665    match sql[i + 1..].find(tag) {
666        Some(off) => Some(i + 1 + off + tag.len()),
667        None => Some(bytes.len()),
668    }
669}
670
671/// Try to match one of `keywords` (ASCII, possibly multi-word like
672/// "GROUP BY") at byte offset `pos`, case-insensitively. Words may be
673/// separated by any run of whitespace/comments. Returns the matched keyword
674/// and the offset just past its last word.
675fn match_keyword_at<'k>(sql: &str, pos: usize, keywords: &[&'k str]) -> Option<(&'k str, usize)> {
676    let bytes = sql.as_bytes();
677    'keywords: for keyword in keywords {
678        let mut p = pos;
679        for (index, word) in keyword.split_ascii_whitespace().enumerate() {
680            if index > 0 {
681                p = skip_ws(sql, p);
682            }
683            let end = p + word.len();
684            if end > bytes.len() || !bytes[p..end].eq_ignore_ascii_case(word.as_bytes()) {
685                continue 'keywords;
686            }
687            // Must not be glued to an identifier character ("ORDERS" ≠ "ORDER").
688            if end < bytes.len() && is_ident_byte(bytes[end]) {
689                continue 'keywords;
690            }
691            p = end;
692        }
693        return Some((keyword, p));
694    }
695    None
696}
697
698/// Find the first occurrence of any of `keywords` at parenthesis depth 0,
699/// outside string literals, quoted identifiers and comments. Returns the
700/// start offset of the match and the offset just past it; both index the
701/// original string.
702fn find_top_level_keyword(sql: &str, keywords: &[&str]) -> Option<(usize, usize)> {
703    if keywords.is_empty() {
704        return None;
705    }
706    let bytes = sql.as_bytes();
707    let mut depth = 0usize;
708    let mut i = 0usize;
709    while i < bytes.len() {
710        let b = bytes[i];
711        if matches!(b, b'\'' | b'"' | b'`' | b'[') {
712            i = skip_quoted(sql, i);
713        } else if b == b'$' {
714            i = skip_dollar_quoted(sql, i).unwrap_or(i + 1);
715        } else if let Some(next) = skip_comment(sql, i) {
716            i = next;
717        } else if b == b'(' {
718            depth += 1;
719            i += 1;
720        } else if b == b')' {
721            depth = depth.saturating_sub(1);
722            i += 1;
723        } else if depth == 0 && b.is_ascii_alphabetic() && (i == 0 || !is_ident_byte(bytes[i - 1]))
724        {
725            if let Some((_, end)) = match_keyword_at(sql, i, keywords) {
726                return Some((i, end));
727            }
728            i += 1;
729        } else {
730            i += 1;
731        }
732    }
733    None
734}
735
736/// Given `open` pointing at '(', return the offset of its matching ')',
737/// skipping literals and comments.
738fn find_matching_paren(sql: &str, open: usize) -> Option<usize> {
739    let bytes = sql.as_bytes();
740    let mut depth = 0usize;
741    let mut i = open;
742    while i < bytes.len() {
743        let b = bytes[i];
744        if matches!(b, b'\'' | b'"' | b'`' | b'[') {
745            i = skip_quoted(sql, i);
746        } else if b == b'$' {
747            i = skip_dollar_quoted(sql, i).unwrap_or(i + 1);
748        } else if let Some(next) = skip_comment(sql, i) {
749            i = next;
750        } else if b == b'(' {
751            depth += 1;
752            i += 1;
753        } else if b == b')' {
754            depth -= 1;
755            if depth == 0 {
756                return Some(i);
757            }
758            i += 1;
759        } else {
760            i += 1;
761        }
762    }
763    None
764}
765
766/// Split a comma-separated list at parenthesis depth 0, ignoring commas
767/// inside literals, quoted identifiers and comments.
768fn split_top_level_commas(list: &str) -> Vec<&str> {
769    let bytes = list.as_bytes();
770    let mut parts = Vec::new();
771    let mut depth = 0usize;
772    let mut start = 0usize;
773    let mut i = 0usize;
774    while i < bytes.len() {
775        let b = bytes[i];
776        if matches!(b, b'\'' | b'"' | b'`' | b'[') {
777            i = skip_quoted(list, i);
778        } else if b == b'$' {
779            i = skip_dollar_quoted(list, i).unwrap_or(i + 1);
780        } else if let Some(next) = skip_comment(list, i) {
781            i = next;
782        } else if b == b'(' {
783            depth += 1;
784            i += 1;
785        } else if b == b')' {
786            depth = depth.saturating_sub(1);
787            i += 1;
788        } else if b == b',' && depth == 0 {
789            parts.push(&list[start..i]);
790            i += 1;
791            start = i;
792        } else {
793            i += 1;
794        }
795    }
796    parts.push(&list[start..]);
797    parts
798}
799
800/// Parse a (possibly schema-qualified, possibly quoted) table name starting
801/// at `pos`. Returns the offset just past the name.
802fn parse_table_name(sql: &str, pos: usize) -> Option<usize> {
803    let bytes = sql.as_bytes();
804    let mut i = pos;
805    loop {
806        i = skip_ws(sql, i);
807        match bytes.get(i) {
808            Some(b'"') | Some(b'`') | Some(b'[') => i = skip_quoted(sql, i),
809            Some(b) if b.is_ascii_alphabetic() || *b == b'_' => {
810                while i < bytes.len() && is_ident_byte(bytes[i]) {
811                    i += 1;
812                }
813            }
814            _ => return None,
815        }
816        // Continue only for a dotted qualifier (schema.table).
817        let next = skip_ws(sql, i);
818        if bytes.get(next) == Some(&b'.') {
819            i = next + 1;
820        } else {
821            return Some(i);
822        }
823    }
824}
825
826/// Strip one level of identifier quoting (`"x"`, `` `x` ``, `[x]`).
827fn unquote_ident(ident: &str) -> &str {
828    let bytes = ident.as_bytes();
829    if ident.len() >= 2 {
830        let (first, last) = (bytes[0], bytes[ident.len() - 1]);
831        if (first == b'"' && last == b'"')
832            || (first == b'`' && last == b'`')
833            || (first == b'[' && last == b']')
834        {
835            return &ident[1..ident.len() - 1];
836        }
837    }
838    ident
839}
840
841/// Check whether `ident` occurs in `sql` as an identifier — outside string
842/// literals and comments — at any nesting depth. Quoted occurrences count.
843fn references_identifier(sql: &str, ident: &str) -> bool {
844    let bytes = sql.as_bytes();
845    let mut i = 0usize;
846    while i < bytes.len() {
847        let b = bytes[i];
848        if b == b'\'' {
849            i = skip_quoted(sql, i);
850        } else if b == b'"' || b == b'`' || b == b'[' {
851            let end = skip_quoted(sql, i);
852            if end > i + 2 && bytes[i + 1..end - 1].eq_ignore_ascii_case(ident.as_bytes()) {
853                return true;
854            }
855            i = end;
856        } else if b == b'$' {
857            // Dollar-quoted string content is literal text, not an identifier.
858            i = skip_dollar_quoted(sql, i).unwrap_or(i + 1);
859        } else if let Some(next) = skip_comment(sql, i) {
860            i = next;
861        } else if b.is_ascii_alphabetic() || b == b'_' {
862            let start = i;
863            while i < bytes.len() && is_ident_byte(bytes[i]) {
864                i += 1;
865            }
866            if bytes[start..i].eq_ignore_ascii_case(ident.as_bytes()) {
867                return true;
868            }
869        } else {
870            i += 1;
871        }
872    }
873    false
874}
875
876/// A scoped tenant context that clears on drop.
877pub struct TenantScope {
878    middleware: Arc<TenantMiddleware>,
879}
880
881impl Drop for TenantScope {
882    fn drop(&mut self) {
883        self.middleware.clear_tenant();
884    }
885}
886
887#[cfg(test)]
888mod tests {
889    use super::*;
890
891    #[test]
892    fn test_row_level_filter_select() {
893        let config = TenantConfig::row_level("tenant_id");
894        let middleware = TenantMiddleware::new(config);
895
896        let sql = middleware
897            .apply_row_level_filter("SELECT * FROM users", "tenant-123")
898            .unwrap();
899        assert!(sql.contains("WHERE tenant_id = 'tenant-123'"));
900
901        let sql = middleware
902            .apply_row_level_filter("SELECT * FROM users WHERE active = true", "tenant-123")
903            .unwrap();
904        assert!(sql.contains("tenant_id = 'tenant-123' AND (active = true)"));
905    }
906
907    #[test]
908    fn test_row_level_filter_update() {
909        let config = TenantConfig::row_level("tenant_id");
910        let middleware = TenantMiddleware::new(config);
911
912        let sql = middleware
913            .apply_row_level_filter("UPDATE users SET name = 'Bob'", "tenant-123")
914            .unwrap();
915        assert!(sql.contains("WHERE tenant_id = 'tenant-123'"));
916
917        let sql = middleware
918            .apply_row_level_filter("UPDATE users SET name = 'Bob' WHERE id = 1", "tenant-123")
919            .unwrap();
920        assert!(sql.contains("tenant_id = 'tenant-123' AND (id = 1)"));
921    }
922
923    #[test]
924    fn test_row_level_filter_delete() {
925        let config = TenantConfig::row_level("tenant_id");
926        let middleware = TenantMiddleware::new(config);
927
928        let sql = middleware
929            .apply_row_level_filter("DELETE FROM users", "tenant-123")
930            .unwrap();
931        assert!(sql.contains("WHERE tenant_id = 'tenant-123'"));
932    }
933
934    #[test]
935    fn test_tenant_scope() {
936        let config = TenantConfig::row_level("tenant_id");
937        let middleware = TenantMiddleware::new(config);
938
939        {
940            let _scope = middleware.scoped(TenantContext::new("tenant-123"));
941            assert!(middleware.current_tenant().is_some());
942            assert_eq!(
943                middleware.current_tenant().unwrap().id.as_str(),
944                "tenant-123"
945            );
946        }
947
948        // Scope dropped, tenant cleared
949        assert!(middleware.current_tenant().is_none());
950    }
951
952    #[test]
953    fn test_integer_tenant_injection_rejected() {
954        use super::super::strategy::{IsolationStrategy, RowLevelConfig};
955
956        let mut config = TenantConfig::row_level("tenant_id");
957        config.strategy = IsolationStrategy::RowLevel(
958            RowLevelConfig::new("tenant_id").with_column_type(ColumnType::Integer),
959        );
960        let middleware = TenantMiddleware::new(config);
961
962        // A malicious tenant id must be rejected, never interpolated.
963        assert!(
964            middleware
965                .apply_row_level_filter("SELECT * FROM users", "1 OR true--")
966                .is_err()
967        );
968
969        // A well-formed integer tenant id is still applied.
970        let sql = middleware
971            .apply_row_level_filter("SELECT * FROM users", "42")
972            .unwrap();
973        assert!(sql.contains("WHERE tenant_id = 42"));
974    }
975
976    #[test]
977    fn test_or_predicate_is_parenthesized() {
978        let config = TenantConfig::row_level("tenant_id");
979        let middleware = TenantMiddleware::new(config);
980
981        let sql = middleware
982            .apply_row_level_filter(
983                "SELECT * FROM users WHERE active = true OR admin = true",
984                "tenant-123",
985            )
986            .unwrap();
987
988        // The existing predicate is parenthesized so the OR branch cannot
989        // bypass the tenant filter.
990        assert!(sql.contains("tenant_id = 'tenant-123' AND (active = true OR admin = true)"));
991    }
992
993    #[test]
994    fn test_group_by_where_placement() {
995        let config = TenantConfig::row_level("tenant_id");
996        let middleware = TenantMiddleware::new(config);
997
998        // No WHERE: the injected clause must land before GROUP BY.
999        let sql = middleware
1000            .apply_row_level_filter(
1001                "SELECT role, COUNT(*) FROM users GROUP BY role",
1002                "tenant-123",
1003            )
1004            .unwrap();
1005        let where_pos = sql.find("WHERE").expect("WHERE injected");
1006        let group_pos = sql.find("GROUP BY").expect("GROUP BY preserved");
1007        assert!(where_pos < group_pos, "unexpected SQL: {sql}");
1008
1009        // Existing WHERE: the closing paren must land before GROUP BY.
1010        let sql = middleware
1011            .apply_row_level_filter(
1012                "SELECT role, COUNT(*) FROM users WHERE active = true GROUP BY role",
1013                "tenant-123",
1014            )
1015            .unwrap();
1016        assert!(sql.contains("tenant_id = 'tenant-123' AND (active = true) GROUP BY role"));
1017    }
1018
1019    #[test]
1020    fn test_string_tenant_whitelist_enforced() {
1021        let config = TenantConfig::row_level("tenant_id");
1022        let middleware = TenantMiddleware::new(config);
1023
1024        // Quote/backslash escape attempts (e.g. MySQL `\'`) and empty ids are
1025        // rejected outright instead of being escaped.
1026        for bad in [
1027            "' OR 1=1-- ",
1028            "\\' OR 1=1-- ",
1029            "tenant'; DROP TABLE users--",
1030            "a b",
1031            "",
1032        ] {
1033            assert!(
1034                middleware
1035                    .apply_row_level_filter("SELECT * FROM users", bad)
1036                    .is_err(),
1037                "tenant id {bad:?} must be rejected"
1038            );
1039        }
1040
1041        // Letters, digits and `_`, `-`, `:`, `.`, `@` are accepted.
1042        for good in ["tenant-123", "a_b-c:d.e@f", "Tenant_01"] {
1043            let sql = middleware
1044                .apply_row_level_filter("SELECT * FROM users", good)
1045                .unwrap();
1046            assert!(
1047                sql.contains(&format!("tenant_id = '{good}'")),
1048                "unexpected SQL: {sql}"
1049            );
1050        }
1051    }
1052
1053    #[test]
1054    fn test_cte_and_comment_prefixed_statements() {
1055        let config = TenantConfig::row_level("tenant_id");
1056        let middleware = TenantMiddleware::new(config);
1057
1058        // WITH … SELECT is rejected loudly: scoping a CTE requires rewriting
1059        // inside subqueries.
1060        let err = middleware
1061            .apply_row_level_filter(
1062                "WITH active_users AS (SELECT * FROM users) SELECT * FROM active_users",
1063                "tenant-123",
1064            )
1065            .unwrap_err();
1066        assert!(
1067            err.to_string().contains("CTE-wrapped SELECT"),
1068            "unexpected error: {err}"
1069        );
1070
1071        // Comment-prefixed SELECT is classified after skipping the comment.
1072        for sql in [
1073            "-- list users\nSELECT * FROM users",
1074            "/* audit */ SELECT * FROM users",
1075        ] {
1076            let rewritten = middleware
1077                .apply_row_level_filter(sql, "tenant-123")
1078                .unwrap();
1079            assert!(
1080                rewritten.contains("WHERE tenant_id = 'tenant-123'"),
1081                "unexpected SQL: {rewritten}"
1082            );
1083        }
1084
1085        // WITH … UPDATE hits the write path: the filter is injected into the
1086        // top-level WHERE clause.
1087        let sql = middleware
1088            .apply_row_level_filter(
1089                "WITH t AS (SELECT 1) UPDATE users SET name = 'Bob'",
1090                "tenant-123",
1091            )
1092            .unwrap();
1093        assert!(
1094            sql.contains("WHERE tenant_id = 'tenant-123'"),
1095            "unexpected SQL: {sql}"
1096        );
1097    }
1098
1099    #[test]
1100    fn test_unrecognized_statements_fail_closed() {
1101        let config = TenantConfig::row_level("tenant_id");
1102        let middleware = TenantMiddleware::new(config);
1103
1104        // Unrecognized shapes are rejected instead of passing through
1105        // unfiltered.
1106        for sql in [
1107            "REPLACE INTO users (id) VALUES (1)",
1108            "MERGE INTO users USING src ON users.id = src.id WHEN MATCHED THEN UPDATE SET name = src.name",
1109            "INSERT INTO users SELECT * FROM staging",
1110            "VACUUM users",
1111        ] {
1112            assert!(
1113                middleware
1114                    .apply_row_level_filter(sql, "tenant-123")
1115                    .is_err(),
1116                "statement must be rejected: {sql}"
1117            );
1118        }
1119    }
1120
1121    #[test]
1122    fn test_insert_existing_tenant_column_must_match() {
1123        let config = TenantConfig::row_level("tenant_id");
1124        let middleware = TenantMiddleware::new(config);
1125
1126        // A pre-existing tenant column holding the current tenant's value
1127        // passes through (whitespace is normalized).
1128        for sql in [
1129            "INSERT INTO users (name, tenant_id) VALUES ('Bob', 'tenant-123')",
1130            "INSERT INTO users (name, tenant_id) VALUES ('Bob',   'tenant-123'  )",
1131        ] {
1132            let rewritten = middleware
1133                .apply_row_level_filter(sql, "tenant-123")
1134                .unwrap();
1135            assert_eq!(rewritten, sql);
1136        }
1137
1138        // A different tenant's value is rejected: tenant A cannot write rows
1139        // owned by tenant B.
1140        assert!(
1141            middleware
1142                .apply_row_level_filter(
1143                    "INSERT INTO users (name, tenant_id) VALUES ('Bob', 'B')",
1144                    "tenant-123",
1145                )
1146                .is_err()
1147        );
1148
1149        // Anything that isn't exactly the validated literal is rejected.
1150        assert!(
1151            middleware
1152                .apply_row_level_filter(
1153                    "INSERT INTO users (name, tenant_id) VALUES ('Bob', 'tenant-123' OR '1'='1')",
1154                    "tenant-123",
1155                )
1156                .is_err()
1157        );
1158    }
1159
1160    #[test]
1161    fn test_insert_auto_injects_tenant_column() {
1162        let config = TenantConfig::row_level("tenant_id");
1163        let middleware = TenantMiddleware::new(config);
1164
1165        let sql = middleware
1166            .apply_row_level_filter("INSERT INTO users (name) VALUES ('Bob')", "tenant-123")
1167            .unwrap();
1168        assert!(sql.contains("tenant_id"), "unexpected SQL: {sql}");
1169        assert!(sql.contains("'tenant-123'"), "unexpected SQL: {sql}");
1170    }
1171
1172    #[test]
1173    fn test_bracket_doubled_escape_scans_as_one_identifier() {
1174        // `[a]]b]` is a single T-SQL identifier (`a]b`): the doubled `]]` is
1175        // T-SQL's escape, not the end of the quoted identifier.
1176        assert_eq!(skip_quoted("[a]]b]", 0), "[a]]b]".len());
1177
1178        // A clause keyword inside a bracket-quoted identifier must not be
1179        // treated as a real clause boundary.
1180        let config = TenantConfig::row_level("tenant_id");
1181        let middleware = TenantMiddleware::new(config);
1182        let sql = middleware
1183            .apply_row_level_filter("SELECT * FROM [a]] WHERE b]", "tenant-123")
1184            .unwrap();
1185        assert!(
1186            sql.ends_with("WHERE tenant_id = 'tenant-123'"),
1187            "unexpected SQL: {sql}"
1188        );
1189    }
1190
1191    #[test]
1192    fn test_dollar_quoted_strings_are_not_scanned() {
1193        let config = TenantConfig::row_level("tenant_id");
1194        let middleware = TenantMiddleware::new(config);
1195
1196        // A clause keyword inside a tagged dollar-quoted literal (e.g. a
1197        // PostgreSQL function body) is not a clause boundary.
1198        let sql = middleware
1199            .apply_row_level_filter(
1200                "SELECT * FROM users WHERE note = $tag$x GROUP BY y$tag$",
1201                "tenant-123",
1202            )
1203            .unwrap();
1204        assert!(
1205            sql.contains("tenant_id = 'tenant-123' AND (note = $tag$x GROUP BY y$tag$)"),
1206            "unexpected SQL: {sql}"
1207        );
1208
1209        // Empty-tag dollar quotes; the real LIMIT is still recognized.
1210        let sql = middleware
1211            .apply_row_level_filter("SELECT $$a LIMIT b$$ FROM users LIMIT 5", "tenant-123")
1212            .unwrap();
1213        assert_eq!(
1214            sql,
1215            "SELECT $$a LIMIT b$$ FROM users WHERE tenant_id = 'tenant-123' LIMIT 5"
1216        );
1217
1218        // A WHERE inside a dollar-quoted literal doesn't count.
1219        let sql = middleware
1220            .apply_row_level_filter("SELECT $$x WHERE y$$ AS v FROM users", "tenant-123")
1221            .unwrap();
1222        assert_eq!(
1223            sql,
1224            "SELECT $$x WHERE y$$ AS v FROM users WHERE tenant_id = 'tenant-123'"
1225        );
1226
1227        // `$1` is a parameter placeholder, not a dollar-quote opener.
1228        let sql = middleware
1229            .apply_row_level_filter("SELECT * FROM users WHERE id = $1", "tenant-123")
1230            .unwrap();
1231        assert!(
1232            sql.contains("tenant_id = 'tenant-123' AND (id = $1)"),
1233            "unexpected SQL: {sql}"
1234        );
1235    }
1236
1237    #[test]
1238    fn test_nested_block_comments_are_skipped() {
1239        let config = TenantConfig::row_level("tenant_id");
1240        let middleware = TenantMiddleware::new(config);
1241
1242        // PostgreSQL nests block comments: the `WHERE` after the inner
1243        // comment close is still comment text, not a clause.
1244        let sql = middleware
1245            .apply_row_level_filter(
1246                "SELECT * FROM users /* outer /* inner */ WHERE x */",
1247                "tenant-123",
1248            )
1249            .unwrap();
1250        assert_eq!(
1251            sql,
1252            "SELECT * FROM users /* outer /* inner */ WHERE x */ WHERE tenant_id = 'tenant-123'"
1253        );
1254    }
1255
1256    #[test]
1257    fn test_window_and_fetch_terminate_where() {
1258        let config = TenantConfig::row_level("tenant_id");
1259        let middleware = TenantMiddleware::new(config);
1260
1261        // The filter must land before the WINDOW clause, not after it.
1262        let sql = middleware
1263            .apply_row_level_filter(
1264                "SELECT row_number() OVER w FROM users WINDOW w AS (ORDER BY id)",
1265                "tenant-123",
1266            )
1267            .unwrap();
1268        assert_eq!(
1269            sql,
1270            "SELECT row_number() OVER w FROM users WHERE tenant_id = 'tenant-123' \
1271             WINDOW w AS (ORDER BY id)"
1272        );
1273
1274        // … and before FETCH FIRST (standard-SQL row limiting).
1275        let sql = middleware
1276            .apply_row_level_filter("SELECT * FROM users FETCH FIRST 10 ROWS ONLY", "tenant-123")
1277            .unwrap();
1278        assert_eq!(
1279            sql,
1280            "SELECT * FROM users WHERE tenant_id = 'tenant-123' FETCH FIRST 10 ROWS ONLY"
1281        );
1282    }
1283
1284    #[tokio::test]
1285    async fn test_task_local_tenant_resolution() {
1286        let config = TenantConfig::row_level("tenant_id");
1287        let middleware = TenantMiddleware::new(config);
1288        middleware.set_tenant(TenantContext::new("slot-tenant"));
1289
1290        // Inside a task-local scope, the task-local tenant wins.
1291        task_local::with_tenant("task-tenant", async {
1292            let response = middleware
1293                .handle(
1294                    QueryContext::new("SELECT * FROM users", vec![]),
1295                    echo_next(),
1296                )
1297                .await
1298                .unwrap();
1299            let sql = response.data["sql"].as_str().unwrap();
1300            assert!(
1301                sql.contains("tenant_id = 'task-tenant'"),
1302                "unexpected SQL: {sql}"
1303            );
1304        })
1305        .await;
1306
1307        // Outside a task-local scope the shared slot is used (backward compat).
1308        let response = middleware
1309            .handle(
1310                QueryContext::new("SELECT * FROM users", vec![]),
1311                echo_next(),
1312            )
1313            .await
1314            .unwrap();
1315        let sql = response.data["sql"].as_str().unwrap();
1316        assert!(
1317            sql.contains("tenant_id = 'slot-tenant'"),
1318            "unexpected SQL: {sql}"
1319        );
1320    }
1321
1322    /// Terminal handler that echoes back the SQL it receives.
1323    fn echo_next<'a>() -> Next<'a> {
1324        Next {
1325            inner: Box::new(|ctx: QueryContext| {
1326                let sql = ctx.sql().to_string();
1327                Box::pin(async move {
1328                    Ok::<QueryResponse, QueryError>(QueryResponse::new(
1329                        serde_json::json!({ "sql": sql }),
1330                    ))
1331                })
1332            }),
1333        }
1334    }
1335}