1use 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
11pub struct TenantMiddleware {
13 config: TenantConfig,
14 current_tenant: Arc<RwLock<Option<TenantContext>>>,
15}
16
17impl TenantMiddleware {
18 pub fn new(config: TenantConfig) -> Self {
20 Self {
21 config,
22 current_tenant: Arc::new(RwLock::new(None)),
23 }
24 }
25
26 pub fn set_tenant(&self, ctx: TenantContext) {
34 *self.current_tenant.write().expect("lock poisoned") = Some(ctx);
35 }
36
37 pub fn clear_tenant(&self) {
39 *self.current_tenant.write().expect("lock poisoned") = None;
40 }
41
42 pub fn current_tenant(&self) -> Option<TenantContext> {
44 self.current_tenant.read().expect("lock poisoned").clone()
45 }
46
47 pub fn scoped(&self, ctx: TenantContext) -> TenantScope {
49 self.set_tenant(ctx);
50 TenantScope {
51 middleware: Arc::new(self.clone()),
52 }
53 }
54
55 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 self.inject_tenant_filter(sql, &config.column, &tenant_value)
66 }
67
68 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 shape @ (StatementShape::Select | StatementShape::Write) => {
80 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 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 StatementShape::WithSelect => Err(QueryError::invalid_input(
116 "sql",
117 "cannot safely apply a tenant filter to a CTE-wrapped SELECT statement",
118 )),
119
120 StatementShape::Other => Err(QueryError::invalid_input(
123 "sql",
124 "cannot safely apply a tenant filter: unrecognized statement shape",
125 )),
126 }
127 }
128
129 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 let tenant_ctx = match task_local::current_tenant().or_else(|| self.current_tenant()) {
166 Some(ctx) => ctx,
167 None => {
168 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 return next.run(ctx).await;
180 }
181 }
182 };
183
184 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 if self.config.strategy.is_row_level() {
198 let query_type = ctx.query_type();
199
200 let modified_sql =
202 self.apply_row_level_filter(ctx.sql(), tenant_ctx.id.as_str())?;
203
204 ctx = ctx.with_sql(modified_sql);
206
207 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 if self.config.strategy.is_schema_based()
231 && let Some(search_path) = self.apply_schema_isolation(tenant_ctx.id.as_str())
232 {
233 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 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 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 ctx.metadata_mut().tenant_id = Some(tenant_ctx.id.to_string());
264
265 next.run(ctx).await
267 })
268 }
269
270 fn name(&self) -> &'static str {
271 "TenantMiddleware"
272 }
273}
274
275fn 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
315fn 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 (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 (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 (None, Some((term_start, _))) => Ok(format!(
369 "{} WHERE {} {}",
370 sql[..term_start].trim_end(),
371 filter,
372 sql[term_start..].trim_start()
373 )),
374 (None, None) => Ok(format!("{} WHERE {}", sql.trim_end(), filter)),
376 }
377}
378
379fn 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 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 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 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
467fn 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
477enum StatementShape {
478 Select,
480 Write,
484 Insert,
486 WithSelect,
490 Other,
492}
493
494fn 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
520fn cte_body_offset(sql: &str, mut pos: usize) -> Option<usize> {
525 let bytes = sql.as_bytes();
526 pos = skip_ws(sql, pos);
528 if let Some((_, end)) = match_keyword_at(sql, pos, &["RECURSIVE"]) {
529 pos = end;
530 }
531 loop {
532 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 if bytes.get(pos) == Some(&b'(') {
547 pos = find_matching_paren(sql, pos)? + 1;
548 pos = skip_ws(sql, pos);
549 }
550 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
565fn is_ident_byte(b: u8) -> bool {
567 b.is_ascii_alphanumeric() || b == b'_' || b == b'$'
568}
569
570fn 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
603fn 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
617fn 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; continue;
633 }
634 return i + 1;
635 }
636 i += 1;
637 }
638 bytes.len()
639}
640
641fn 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 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
671fn 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 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
698fn 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
736fn 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
766fn 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
800fn 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 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
826fn 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
841fn 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 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
876pub 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 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 assert!(
964 middleware
965 .apply_row_level_filter("SELECT * FROM users", "1 OR true--")
966 .is_err()
967 );
968
969 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 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 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 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 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 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 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 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 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 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 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 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 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 assert_eq!(skip_quoted("[a]]b]", 0), "[a]]b]".len());
1177
1178 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 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 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 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 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 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 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 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 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 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 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}