1use crate::ast::{
36 Action, Cage, CageKind, Condition, ConflictAction, Expr, JoinKind, LogicalOp, MergeAction,
37 MergeMatchKind, MergeSource, Operator, Qail, Value,
38};
39use crate::error::{QailBuildError, QailBuildResult};
40use crate::rls::RlsContext;
41use crate::rls::owner::try_lookup_owner_column;
42use crate::rls::tenant::try_lookup_tenant_column;
43
44fn tenant_column_for(table: &str) -> QailBuildResult<Option<String>> {
48 map_registry_lookup(table, try_lookup_tenant_column(table))
49}
50
51fn owner_column_for(table: &str) -> QailBuildResult<Option<String>> {
52 map_registry_lookup(table, try_lookup_owner_column(table))
53}
54
55fn map_registry_lookup(
59 table: &str,
60 lookup: Result<Option<String>, String>,
61) -> QailBuildResult<Option<String>> {
62 lookup.map_err(|reason| QailBuildError::RlsRegistryUnavailable {
63 table: table.to_string(),
64 reason,
65 })
66}
67
68fn normalize_ident(raw: &str) -> String {
69 let trimmed = raw.trim();
70 if trimmed.starts_with('$') {
71 return trimmed.to_string();
72 }
73
74 let segment = trimmed.rsplit('.').next().unwrap_or(trimmed).trim();
75 let unquoted = if segment.len() >= 2 {
76 let bytes = segment.as_bytes();
77 let first = bytes[0] as char;
78 let last = bytes[bytes.len() - 1] as char;
79 if (first == '"' && last == '"')
80 || (first == '`' && last == '`')
81 || (first == '[' && last == ']')
82 {
83 &segment[1..segment.len() - 1]
84 } else {
85 segment
86 }
87 } else {
88 segment
89 };
90 unquoted.to_ascii_lowercase()
91}
92
93fn split_table_reference(table_ref: &str) -> (&str, Option<&str>) {
94 let parts = table_ref.split_whitespace().collect::<Vec<_>>();
95 match parts.as_slice() {
96 [table, alias] => (table, Some(alias)),
97 [table, as_keyword, alias] if as_keyword.eq_ignore_ascii_case("as") => (table, Some(alias)),
98 _ => (table_ref.trim(), None),
99 }
100}
101
102fn expr_named_eq(expr: &Expr, name: &str) -> bool {
103 matches!(expr, Expr::Named(existing) if normalize_ident(existing) == normalize_ident(name))
104}
105
106fn is_tenant_column_condition(cond: &Condition, tenant_col: &str) -> bool {
107 expr_named_eq(&cond.left, tenant_col)
108}
109
110fn column_ref_segments(raw: &str) -> Vec<String> {
113 raw.trim().split('.').map(normalize_ident).collect()
114}
115
116fn same_scoped_column(a: &str, b: &str, primary: &str, joined: &[String]) -> bool {
125 let primary = normalize_ident(primary);
126 let joined: Vec<String> = joined.iter().map(|j| normalize_ident(j)).collect();
132 let joined = joined.as_slice();
133 let resolve = |raw: &str| -> Vec<String> {
134 let mut segs = column_ref_segments(raw);
135 if segs.len() > 2 {
143 segs = segs.split_off(segs.len() - 2);
144 }
145 match segs.len() {
146 1 => segs.insert(0, primary.clone()),
147 2 if !joined.contains(&segs[0]) => segs[0] = primary.clone(),
148 _ => {}
149 }
150 segs
151 };
152 resolve(a) == resolve(b)
153}
154
155fn condition_references_tenant_column(cond: &Condition, tenant_col: &str) -> bool {
156 is_tenant_column_condition(cond, tenant_col)
157 || matches!(&cond.value, Value::Column(col) if normalize_ident(col) == normalize_ident(tenant_col))
158}
159
160fn payload_is_positional(cage: &Cage) -> bool {
161 cage.conditions.iter().all(|cond| {
162 matches!(
163 &cond.left,
164 Expr::Named(name) if name.starts_with('$') && name[1..].chars().all(|c| c.is_ascii_digit())
165 )
166 })
167}
168
169fn make_named_condition(column: &str, value: Value) -> Condition {
170 Condition {
171 left: Expr::Named(column.to_string()),
172 op: Operator::Eq,
173 value,
174 is_array_unnest: false,
175 }
176}
177
178fn make_positional_condition(index: usize, value: Value) -> Condition {
179 Condition {
180 left: Expr::Named(format!("${}", index + 1)),
181 op: Operator::Eq,
182 value,
183 is_array_unnest: false,
184 }
185}
186
187fn expr_projects_all_columns(expr: &Expr) -> bool {
188 matches!(expr, Expr::Star)
189 || matches!(expr, Expr::Named(name) if name == "*" || name.trim().ends_with(".*"))
190}
191
192fn expr_projects_tenant_col(expr: &Expr, tenant_col: &str) -> bool {
193 match expr {
194 Expr::Named(name) => normalize_ident(name) == normalize_ident(tenant_col),
195 Expr::Aliased { alias, .. } => normalize_ident(alias) == normalize_ident(tenant_col),
196 Expr::JsonAccess {
197 alias: Some(alias), ..
198 }
199 | Expr::FunctionCall {
200 alias: Some(alias), ..
201 }
202 | Expr::Cast {
203 alias: Some(alias), ..
204 }
205 | Expr::Binary {
206 alias: Some(alias), ..
207 }
208 | Expr::Case {
209 alias: Some(alias), ..
210 }
211 | Expr::SpecialFunction {
212 alias: Some(alias), ..
213 }
214 | Expr::ArrayConstructor {
215 alias: Some(alias), ..
216 }
217 | Expr::RowConstructor {
218 alias: Some(alias), ..
219 }
220 | Expr::Subscript {
221 alias: Some(alias), ..
222 }
223 | Expr::Collate {
224 alias: Some(alias), ..
225 }
226 | Expr::FieldAccess {
227 alias: Some(alias), ..
228 }
229 | Expr::Subquery {
230 alias: Some(alias), ..
231 }
232 | Expr::Exists {
233 alias: Some(alias), ..
234 } => normalize_ident(alias) == normalize_ident(tenant_col),
235 _ => false,
236 }
237}
238
239fn query_projects_tenant_col(query: &Qail, tenant_col: &str) -> bool {
240 query.columns.is_empty()
241 || query.columns.iter().any(|expr| {
242 expr_projects_all_columns(expr) || expr_projects_tenant_col(expr, tenant_col)
243 })
244}
245
246fn query_can_append_tenant_projection(query: &Qail) -> bool {
247 query.set_ops.is_empty()
248 && query.having.is_empty()
249 && !query
250 .columns
251 .iter()
252 .any(|expr| matches!(expr, Expr::Aggregate { .. } | Expr::Window { .. }))
253}
254
255fn ensure_merge_query_source_projects_tenant(
256 mut query: Qail,
257 target_table: &str,
258 tenant_col: &str,
259) -> QailBuildResult<Qail> {
260 if query_projects_tenant_col(&query, tenant_col) {
261 return Ok(query);
262 }
263
264 if !query_can_append_tenant_projection(&query) {
265 return Err(QailBuildError::RlsMergeSourceTenantProjectionRequired {
266 table: target_table.to_string(),
267 tenant_column: tenant_col.to_string(),
268 });
269 }
270
271 query.columns.push(Expr::Named(tenant_col.to_string()));
272 Ok(query)
273}
274
275impl Qail {
276 pub fn with_rls(self, ctx: &RlsContext) -> QailBuildResult<Self> {
307 if ctx.bypasses_rls() {
308 return Ok(self);
309 }
310
311 match crate::rls::scope_registry_state() {
312 crate::rls::ScopeRegistryState::Initialized => {}
313 crate::rls::ScopeRegistryState::PolicyOnly => return Ok(self),
315 crate::rls::ScopeRegistryState::Uninitialized => {
317 return Err(QailBuildError::RlsRegistryUninitialized { table: self.table });
318 }
319 }
320
321 let (base_table, _) = split_table_reference(&self.table);
322 let tenant_col = tenant_column_for(base_table)?;
323 let owner_col = owner_column_for(base_table)?;
324
325 Self::ensure_scope_present(
330 &self.table,
331 tenant_col.as_deref(),
332 owner_col.as_deref(),
333 ctx,
334 )?;
335
336 let mut scoped = self.scope_nested_rls(ctx)?;
341 scoped = scoped.scope_joined_relations(ctx)?;
342
343 if let Some(tenant_col) = tenant_col {
344 scoped = scoped.scope_tenant_dimension(&tenant_col, ctx)?;
345 scoped = scoped.scope_conflict_update(&tenant_col, Self::tenant_scope_value(ctx))?;
346 }
347 if let Some(owner_col) = owner_col {
348 scoped = scoped.scope_owner_dimension(&owner_col, ctx)?;
349 scoped = scoped.scope_conflict_update(
350 &owner_col,
351 Some(Value::String(ctx.user_id().to_string())),
352 )?;
353 }
354 Ok(scoped)
355 }
356
357 fn ensure_scope_present(
358 table: &str,
359 tenant_col: Option<&str>,
360 owner_col: Option<&str>,
361 ctx: &RlsContext,
362 ) -> QailBuildResult<()> {
363 if let Some(col) = tenant_col
364 && !ctx.is_global()
365 && !ctx.has_tenant()
366 {
367 return Err(QailBuildError::RlsScopeMissing {
368 table: table.to_string(),
369 scope: "tenant",
370 column: col.to_string(),
371 });
372 }
373 if let Some(col) = owner_col
374 && !ctx.has_user()
375 {
376 return Err(QailBuildError::RlsScopeMissing {
377 table: table.to_string(),
378 scope: "user",
379 column: col.to_string(),
380 });
381 }
382 Ok(())
383 }
384
385 fn tenant_scope_value(ctx: &RlsContext) -> Option<Value> {
388 if ctx.is_global() {
389 None
390 } else {
391 Some(Value::String(ctx.tenant_id.clone()))
392 }
393 }
394
395 fn scope_condition(col: &str, value: Option<Value>) -> Condition {
396 match value {
397 Some(value) => make_named_condition(col, value),
398 None => Condition {
399 left: Expr::Named(col.to_string()),
400 op: Operator::IsNull,
401 value: Value::Null,
402 is_array_unnest: false,
403 },
404 }
405 }
406
407 fn scope_joined_relations(mut self, ctx: &RlsContext) -> QailBuildResult<Self> {
412 let mut extra_filters: Vec<Condition> = Vec::new();
413 for join in &mut self.joins {
414 let (base, alias) = split_table_reference(&join.table);
415 let tenant_col = tenant_column_for(base)?;
416 let owner_col = owner_column_for(base)?;
417 if tenant_col.is_none() && owner_col.is_none() {
418 continue;
419 }
420 Self::ensure_scope_present(
421 &join.table,
422 tenant_col.as_deref(),
423 owner_col.as_deref(),
424 ctx,
425 )?;
426 let qualifier = alias.unwrap_or(base);
427 let mut conditions = Vec::new();
428 if let Some(col) = tenant_col {
429 conditions.push(Self::scope_condition(
430 &format!("{qualifier}.{col}"),
431 Self::tenant_scope_value(ctx),
432 ));
433 }
434 if let Some(col) = owner_col {
435 conditions.push(Self::scope_condition(
436 &format!("{qualifier}.{col}"),
437 Some(Value::String(ctx.user_id().to_string())),
438 ));
439 }
440 match join.kind {
441 JoinKind::Inner | JoinKind::Left | JoinKind::Lateral => {
442 join.on_true = false;
443 join.on.get_or_insert_with(Vec::new).extend(conditions);
444 }
445 JoinKind::Cross => extra_filters.extend(conditions),
446 JoinKind::Right | JoinKind::Full => {
447 return Err(QailBuildError::RlsJoinKindUnsupported {
448 table: join.table.clone(),
449 join_kind: format!("{:?}", join.kind),
450 });
451 }
452 }
453 }
454 for condition in extra_filters {
455 self = self.scope_to_condition(condition);
456 }
457 Ok(self)
458 }
459
460 fn scope_conflict_update(mut self, col: &str, value: Option<Value>) -> QailBuildResult<Self> {
464 let condition_col = self.primary_tenant_condition_col(col);
465 let primary = self.primary_relation_qualifier();
466 let joined = self.join_qualifiers();
467 let table = self.table.clone();
468 let Some(on_conflict) = self.on_conflict.as_mut() else {
469 return Ok(self);
470 };
471 let ConflictAction::DoUpdate { assignments } = &on_conflict.action else {
472 return Ok(self);
473 };
474 if assignments
475 .iter()
476 .any(|(assigned, _)| normalize_ident(assigned) == normalize_ident(col))
477 {
478 return Err(QailBuildError::RlsTenantColumnMutationDenied {
479 table,
480 tenant_column: col.to_string(),
481 });
482 }
483 on_conflict.where_conditions.retain(|c| {
484 !matches!(&c.left, Expr::Named(existing)
485 if same_scoped_column(existing, &condition_col, &primary, &joined))
486 });
487 on_conflict
488 .where_conditions
489 .push(Self::scope_condition(&condition_col, value));
490 Ok(self)
491 }
492
493 fn scope_tenant_dimension(self, tenant_col: &str, ctx: &RlsContext) -> QailBuildResult<Self> {
494 let scoped = self;
495 if ctx.is_global() {
496 return match scoped.action {
497 Action::Get
498 | Action::Cnt
499 | Action::Del
500 | Action::Over
501 | Action::Gen
502 | Action::Export
503 | Action::Search
504 | Action::Scroll => {
505 let condition_col = scoped.primary_tenant_condition_col(tenant_col);
506 Ok(scoped.scope_to_global(&condition_col))
507 }
508 Action::Set => scoped.scope_update_global(tenant_col),
509 Action::Add | Action::Upsert | Action::Put => {
510 scoped.scope_insert_global(tenant_col)
511 }
512 Action::Merge => scoped.scope_merge_global(tenant_col),
513 _ => Ok(scoped),
514 };
515 }
516
517 match scoped.action {
518 Action::Get
520 | Action::Cnt
521 | Action::Del
522 | Action::Over
523 | Action::Gen
524 | Action::Export
525 | Action::Search
526 | Action::Scroll => {
527 let condition_col = scoped.primary_tenant_condition_col(tenant_col);
528 Ok(scoped.scope_to_tenant(&condition_col, ctx))
529 }
530 Action::Set => scoped.scope_update_tenant(tenant_col, ctx),
531 Action::Add | Action::Upsert | Action::Put => {
533 scoped.scope_insert_tenant(tenant_col, ctx)
534 }
535 Action::Merge => scoped.scope_merge_tenant(tenant_col, ctx),
536 _ => Ok(scoped),
538 }
539 }
540
541 fn scope_owner_dimension(self, owner_col: &str, ctx: &RlsContext) -> QailBuildResult<Self> {
547 let user_id = Value::String(ctx.user_id().to_string());
548 match self.action {
549 Action::Get
550 | Action::Cnt
551 | Action::Del
552 | Action::Over
553 | Action::Gen
554 | Action::Export
555 | Action::Search
556 | Action::Scroll => {
557 let condition_col = self.primary_tenant_condition_col(owner_col);
558 Ok(self.scope_to_value(&condition_col, user_id))
559 }
560 Action::Set => {
561 self.reject_tenant_payload_mutation(owner_col)?;
562 let condition_col = self.primary_tenant_condition_col(owner_col);
563 Ok(self.scope_to_value(&condition_col, user_id))
564 }
565 Action::Add | Action::Upsert | Action::Put => {
566 self.scope_insert_value(owner_col, user_id)
567 }
568 Action::Merge => Err(QailBuildError::RlsOwnerMergeUnsupported {
569 table: self.table,
570 owner_column: owner_col.to_string(),
571 }),
572 _ => Ok(self),
573 }
574 }
575
576 #[must_use]
609 pub fn with_rls_policy(self, _ctx: &RlsContext) -> Self {
610 self
611 }
612
613 fn scope_boxed_query_rls(query: &mut Box<Qail>, ctx: &RlsContext) -> QailBuildResult<()> {
614 let nested = std::mem::take(query.as_mut());
615 **query = nested.with_rls(ctx)?;
616 Ok(())
617 }
618
619 fn scope_nested_rls(mut self, ctx: &RlsContext) -> QailBuildResult<Self> {
620 for cte in &mut self.ctes {
621 Self::scope_boxed_query_rls(&mut cte.base_query, ctx)?;
622 if let Some(ref mut recursive_query) = cte.recursive_query {
623 Self::scope_boxed_query_rls(recursive_query, ctx)?;
624 }
625 }
626
627 if let Some(ref mut source_query) = self.source_query {
628 Self::scope_boxed_query_rls(source_query, ctx)?;
629 }
630
631 for (_, set_query) in &mut self.set_ops {
632 Self::scope_boxed_query_rls(set_query, ctx)?;
633 }
634
635 self.scope_embedded_expr_rls(ctx)?;
636
637 Ok(self)
638 }
639
640 fn scope_value_nested_rls(value: &mut Value, ctx: &RlsContext) -> QailBuildResult<()> {
641 match value {
642 Value::Array(values) => {
643 for value in values {
644 Self::scope_value_nested_rls(value, ctx)?;
645 }
646 }
647 Value::Subquery(query) => {
648 Self::scope_boxed_query_rls(query, ctx)?;
649 }
650 Value::Expr(expr) => Self::scope_expr_nested_rls(expr, ctx)?,
651 _ => {}
652 }
653
654 Ok(())
655 }
656
657 fn scope_condition_nested_rls(
658 condition: &mut Condition,
659 ctx: &RlsContext,
660 ) -> QailBuildResult<()> {
661 Self::scope_expr_nested_rls(&mut condition.left, ctx)?;
662 Self::scope_value_nested_rls(&mut condition.value, ctx)
663 }
664
665 fn scope_expr_nested_rls(expr: &mut Expr, ctx: &RlsContext) -> QailBuildResult<()> {
666 match expr {
667 Expr::Aggregate {
668 filter: Some(filter),
669 ..
670 } => {
671 for condition in filter {
672 Self::scope_condition_nested_rls(condition, ctx)?;
673 }
674 }
675 Expr::Cast { expr, .. } | Expr::Mod { col: expr, .. } | Expr::Collate { expr, .. } => {
676 Self::scope_expr_nested_rls(expr, ctx)?;
677 }
678 Expr::Window { params, order, .. } => {
679 for expr in params {
680 Self::scope_expr_nested_rls(expr, ctx)?;
681 }
682 for cage in order {
683 for condition in &mut cage.conditions {
684 Self::scope_condition_nested_rls(condition, ctx)?;
685 }
686 }
687 }
688 Expr::Case {
689 when_clauses,
690 else_value,
691 ..
692 } => {
693 for (condition, then_expr) in when_clauses {
694 Self::scope_condition_nested_rls(condition, ctx)?;
695 Self::scope_expr_nested_rls(then_expr, ctx)?;
696 }
697 if let Some(expr) = else_value {
698 Self::scope_expr_nested_rls(expr, ctx)?;
699 }
700 }
701 Expr::FunctionCall { args, .. } => {
702 for expr in args {
703 Self::scope_expr_nested_rls(expr, ctx)?;
704 }
705 }
706 Expr::SpecialFunction { args, .. } => {
707 for (_, expr) in args {
708 Self::scope_expr_nested_rls(expr, ctx)?;
709 }
710 }
711 Expr::Binary { left, right, .. } => {
712 Self::scope_expr_nested_rls(left, ctx)?;
713 Self::scope_expr_nested_rls(right, ctx)?;
714 }
715 Expr::Literal(value) => Self::scope_value_nested_rls(value, ctx)?,
716 Expr::ArrayConstructor { elements, .. } | Expr::RowConstructor { elements, .. } => {
717 for expr in elements {
718 Self::scope_expr_nested_rls(expr, ctx)?;
719 }
720 }
721 Expr::Subscript { expr, index, .. } => {
722 Self::scope_expr_nested_rls(expr, ctx)?;
723 Self::scope_expr_nested_rls(index, ctx)?;
724 }
725 Expr::FieldAccess { expr, .. } => Self::scope_expr_nested_rls(expr, ctx)?,
726 Expr::Subquery { query, .. } | Expr::Exists { query, .. } => {
727 Self::scope_boxed_query_rls(query, ctx)?;
728 }
729 Expr::Star
730 | Expr::Named(_)
731 | Expr::Aliased { .. }
732 | Expr::Aggregate { filter: None, .. }
733 | Expr::Def { .. }
734 | Expr::JsonAccess { .. } => {}
735 }
736
737 Ok(())
738 }
739
740 fn scope_embedded_expr_rls(&mut self, ctx: &RlsContext) -> QailBuildResult<()> {
741 for expr in &mut self.columns {
742 Self::scope_expr_nested_rls(expr, ctx)?;
743 }
744 for expr in &mut self.distinct_on {
745 Self::scope_expr_nested_rls(expr, ctx)?;
746 }
747 if let Some(returning) = &mut self.returning {
748 for expr in returning {
749 Self::scope_expr_nested_rls(expr, ctx)?;
750 }
751 }
752 for cage in &mut self.cages {
753 for condition in &mut cage.conditions {
754 Self::scope_condition_nested_rls(condition, ctx)?;
755 }
756 }
757 for condition in &mut self.having {
758 Self::scope_condition_nested_rls(condition, ctx)?;
759 }
760 for join in &mut self.joins {
761 if let Some(conditions) = &mut join.on {
762 for condition in conditions {
763 Self::scope_condition_nested_rls(condition, ctx)?;
764 }
765 }
766 }
767 if let Some(on_conflict) = &mut self.on_conflict {
768 for condition in &mut on_conflict.where_conditions {
769 Self::scope_condition_nested_rls(condition, ctx)?;
770 }
771 }
772 if let Some(on_conflict) = &mut self.on_conflict
773 && let crate::ast::ConflictAction::DoUpdate { assignments } = &mut on_conflict.action
774 {
775 for (_, expr) in assignments {
776 Self::scope_expr_nested_rls(expr, ctx)?;
777 }
778 }
779 if let Some(merge) = &mut self.merge {
780 for condition in &mut merge.on {
781 Self::scope_condition_nested_rls(condition, ctx)?;
782 }
783 for clause in &mut merge.clauses {
784 for condition in &mut clause.condition {
785 Self::scope_condition_nested_rls(condition, ctx)?;
786 }
787 match &mut clause.action {
788 MergeAction::Update { assignments } => {
789 for (_, expr) in assignments {
790 Self::scope_expr_nested_rls(expr, ctx)?;
791 }
792 }
793 MergeAction::Insert { values, .. } => {
794 for expr in values {
795 Self::scope_expr_nested_rls(expr, ctx)?;
796 }
797 }
798 MergeAction::Delete | MergeAction::DoNothing => {}
799 }
800 }
801 }
802
803 Ok(())
804 }
805
806 fn scope_update_tenant(self, tenant_col: &str, ctx: &RlsContext) -> QailBuildResult<Self> {
807 self.reject_tenant_payload_mutation(tenant_col)?;
808 let condition_col = self.primary_tenant_condition_col(tenant_col);
809 Ok(self.scope_to_tenant(&condition_col, ctx))
810 }
811
812 fn scope_update_global(self, tenant_col: &str) -> QailBuildResult<Self> {
813 self.reject_tenant_payload_mutation(tenant_col)?;
814 let condition_col = self.primary_tenant_condition_col(tenant_col);
815 Ok(self.scope_to_global(&condition_col))
816 }
817
818 fn reject_tenant_payload_mutation(&self, tenant_col: &str) -> QailBuildResult<()> {
819 let assigns_tenant = self
820 .cages
821 .iter()
822 .filter(|cage| matches!(cage.kind, CageKind::Payload))
823 .flat_map(|cage| cage.conditions.iter())
824 .any(|cond| expr_named_eq(&cond.left, tenant_col));
825
826 if assigns_tenant {
827 return Err(QailBuildError::RlsTenantColumnMutationDenied {
828 table: self.table.clone(),
829 tenant_column: tenant_col.to_string(),
830 });
831 }
832
833 Ok(())
834 }
835
836 fn scope_to_tenant(self, tenant_col: &str, ctx: &RlsContext) -> Self {
841 self.scope_to_value(tenant_col, Value::String(ctx.tenant_id.clone()))
842 }
843
844 fn scope_to_value(self, col: &str, value: Value) -> Self {
846 self.scope_to_condition(make_named_condition(col, value))
847 }
848
849 fn scope_to_condition(mut self, condition: Condition) -> Self {
850 let col = match &condition.left {
851 Expr::Named(name) => name.clone(),
852 _ => String::new(),
853 };
854 let primary = self.primary_relation_qualifier();
855 let joined = self.join_qualifiers();
856
857 let existing = self
861 .cages
862 .iter_mut()
863 .find(|c| matches!(c.kind, CageKind::Filter) && c.logical_op == LogicalOp::And);
864
865 if let Some(cage) = existing {
866 cage.conditions.retain(|cond| {
867 !matches!(&cond.left, Expr::Named(existing)
868 if same_scoped_column(existing, &col, &primary, &joined))
869 });
870 cage.conditions.push(condition);
871 } else {
872 self.cages.push(Cage {
873 kind: CageKind::Filter,
874 conditions: vec![condition],
875 logical_op: LogicalOp::And,
876 });
877 }
878
879 self
880 }
881
882 fn primary_relation_qualifier(&self) -> String {
884 let (base, alias) = split_table_reference(&self.table);
885 alias.unwrap_or(base).to_string()
886 }
887
888 fn join_qualifiers(&self) -> Vec<String> {
890 self.joins
891 .iter()
892 .map(|join| {
893 let (base, alias) = split_table_reference(&join.table);
894 normalize_ident(alias.unwrap_or(base))
895 })
896 .collect()
897 }
898
899 fn primary_tenant_condition_col(&self, tenant_col: &str) -> String {
900 let (base, alias) = split_table_reference(&self.table);
910 let qualifier = alias.unwrap_or(base);
911 if qualifier.is_empty() {
912 tenant_col.to_string()
913 } else {
914 format!("{qualifier}.{tenant_col}")
915 }
916 }
917
918 fn scope_to_global(self, tenant_col: &str) -> Self {
921 self.scope_to_condition(Self::scope_condition(tenant_col, None))
922 }
923
924 fn scope_insert_tenant(self, tenant_col: &str, ctx: &RlsContext) -> QailBuildResult<Self> {
929 self.scope_insert_value(tenant_col, Value::String(ctx.tenant_id.clone()))
930 }
931
932 fn scope_insert_global(self, tenant_col: &str) -> QailBuildResult<Self> {
934 self.scope_insert_value(tenant_col, Value::Null)
935 }
936
937 fn scope_insert_value(
938 mut self,
939 tenant_col: &str,
940 tenant_value: Value,
941 ) -> QailBuildResult<Self> {
942 let payload_idx = self
943 .cages
944 .iter()
945 .position(|c| matches!(c.kind, CageKind::Payload));
946
947 let Some(idx) = payload_idx else {
948 self.cages.push(Cage {
949 kind: CageKind::Payload,
950 conditions: vec![make_named_condition(tenant_col, tenant_value)],
951 logical_op: LogicalOp::And,
952 });
953 return Ok(self);
954 };
955
956 let positional = payload_is_positional(&self.cages[idx]);
957 if positional {
958 if self.columns.is_empty() {
959 return Err(QailBuildError::RlsInsertRequiresExplicitColumns {
960 table: self.table,
961 tenant_column: tenant_col.to_string(),
962 });
963 }
964
965 if let Some(col_idx) = self
966 .columns
967 .iter()
968 .position(|expr| expr_named_eq(expr, tenant_col))
969 {
970 let placeholder = format!("${}", col_idx + 1);
971 let cage = &mut self.cages[idx];
972 if let Some(cond) = cage
973 .conditions
974 .iter_mut()
975 .find(|cond| expr_named_eq(&cond.left, &placeholder))
976 {
977 cond.value = tenant_value;
978 cond.op = Operator::Eq;
979 cond.is_array_unnest = false;
980 } else {
981 cage.conditions
982 .push(make_positional_condition(col_idx, tenant_value));
983 }
984 return Ok(self);
985 }
986
987 if !self.columns.is_empty() {
988 self.columns.push(Expr::Named(tenant_col.to_string()));
989 let idx_col = self.columns.len() - 1;
990 let cage = &mut self.cages[idx];
991 cage.conditions
992 .push(make_positional_condition(idx_col, tenant_value));
993 return Ok(self);
994 }
995 }
996
997 let cage = &mut self.cages[idx];
998 cage.conditions
999 .retain(|cond| !is_tenant_column_condition(cond, tenant_col));
1000 cage.conditions
1001 .push(make_named_condition(tenant_col, tenant_value));
1002 Ok(self)
1003 }
1004
1005 fn scope_merge_tenant(mut self, tenant_col: &str, ctx: &RlsContext) -> QailBuildResult<Self> {
1006 self.scope_merge_query_source(ctx, tenant_col)?;
1007 self.reject_merge_tenant_update_mutation(tenant_col)?;
1008 let target_col = self.merge_target_tenant_col(tenant_col);
1009 let source_col = self.merge_source_tenant_col(tenant_col)?;
1010 self.scope_merge_on_tenant_equality(tenant_col, target_col.clone(), source_col.clone());
1011
1012 let condition = Condition {
1013 left: Expr::Named(target_col),
1014 op: Operator::Eq,
1015 value: Value::String(ctx.tenant_id.clone()),
1016 is_array_unnest: false,
1017 };
1018 let source_condition = source_col.map(|source_col| Condition {
1019 left: Expr::Named(source_col),
1020 op: Operator::Eq,
1021 value: Value::String(ctx.tenant_id.clone()),
1022 is_array_unnest: false,
1023 });
1024 self.scope_merge_clause_conditions(tenant_col, condition, source_condition);
1025 self.scope_merge_insert_value(
1026 tenant_col,
1027 Expr::Literal(Value::String(ctx.tenant_id.clone())),
1028 )?;
1029 Ok(self)
1030 }
1031
1032 fn scope_merge_global(mut self, tenant_col: &str) -> QailBuildResult<Self> {
1033 self.scope_merge_query_source(&RlsContext::global(), tenant_col)?;
1034 self.reject_merge_tenant_update_mutation(tenant_col)?;
1035 let target_col = self.merge_target_tenant_col(tenant_col);
1036 let source_col = self.merge_source_tenant_col(tenant_col)?;
1037 self.scope_merge_on_tenant_equality(tenant_col, target_col.clone(), source_col.clone());
1038
1039 let condition = Condition {
1040 left: Expr::Named(target_col),
1041 op: Operator::IsNull,
1042 value: Value::Null,
1043 is_array_unnest: false,
1044 };
1045 let source_condition = source_col.map(|source_col| Condition {
1046 left: Expr::Named(source_col),
1047 op: Operator::IsNull,
1048 value: Value::Null,
1049 is_array_unnest: false,
1050 });
1051 self.scope_merge_clause_conditions(tenant_col, condition, source_condition);
1052 self.scope_merge_insert_value(tenant_col, Expr::Literal(Value::Null))?;
1053 Ok(self)
1054 }
1055
1056 fn scope_merge_query_source(
1057 &mut self,
1058 ctx: &RlsContext,
1059 tenant_col: &str,
1060 ) -> QailBuildResult<()> {
1061 let has_query_source = matches!(
1062 self.merge.as_ref().map(|merge| &merge.source),
1063 Some(MergeSource::Query { .. })
1064 );
1065 let Some(source_tenant_col) = self.merge_query_source_tenant_col(tenant_col)? else {
1066 if has_query_source {
1067 return Err(QailBuildError::RlsMergeSourceTenantProjectionRequired {
1068 table: self.table.clone(),
1069 tenant_column: tenant_col.to_string(),
1070 });
1071 }
1072 return Ok(());
1073 };
1074 let target_table = self.table.clone();
1075
1076 let Some(merge) = &mut self.merge else {
1077 return Ok(());
1078 };
1079 let MergeSource::Query { query, .. } = &mut merge.source else {
1080 return Ok(());
1081 };
1082
1083 let scoped_query = std::mem::take(query.as_mut()).with_rls(ctx)?;
1084 let scoped_query = ensure_merge_query_source_projects_tenant(
1085 scoped_query,
1086 &target_table,
1087 &source_tenant_col,
1088 )?;
1089 **query = scoped_query;
1090 Ok(())
1091 }
1092
1093 fn merge_target_tenant_col(&self, tenant_col: &str) -> String {
1094 let (target_table, inline_alias) = split_table_reference(&self.table);
1095 let qualifier = self
1096 .merge
1097 .as_ref()
1098 .and_then(|merge| merge.target_alias.as_ref())
1099 .map(String::as_str)
1100 .or(inline_alias)
1101 .unwrap_or(target_table);
1102 format!("{qualifier}.{tenant_col}")
1103 }
1104
1105 fn merge_source_tenant_col(&self, tenant_col: &str) -> QailBuildResult<Option<String>> {
1106 let Some(merge) = self.merge.as_ref() else {
1107 return Ok(None);
1108 };
1109 match &merge.source {
1110 MergeSource::Table { name, alias } => {
1111 let (source_table, inline_alias) = split_table_reference(name);
1112 let Some(source_tenant_col) = tenant_column_for(source_table)? else {
1113 return Ok(None);
1114 };
1115 let qualifier = alias.as_deref().or(inline_alias).unwrap_or(source_table);
1116 Ok(Some(format!("{qualifier}.{source_tenant_col}")))
1117 }
1118 MergeSource::Query { query, alias } => {
1119 let Some(source_tenant_col) = self.merge_query_source_tenant_col(tenant_col)?
1120 else {
1121 return Ok(None);
1122 };
1123 let Some(qualifier) = alias.as_deref() else {
1124 return Ok(None);
1125 };
1126 if query_projects_tenant_col(query, &source_tenant_col) {
1127 Ok(Some(format!("{qualifier}.{source_tenant_col}")))
1128 } else {
1129 Ok(None)
1130 }
1131 }
1132 }
1133 }
1134
1135 fn merge_query_source_tenant_col(&self, tenant_col: &str) -> QailBuildResult<Option<String>> {
1136 let Some(merge) = self.merge.as_ref() else {
1137 return Ok(None);
1138 };
1139 let MergeSource::Query { query, .. } = &merge.source else {
1140 return Ok(None);
1141 };
1142
1143 let (source_table, _) = split_table_reference(&query.table);
1144 if let Some(source_tenant_col) = tenant_column_for(source_table)? {
1145 return Ok(Some(source_tenant_col));
1146 }
1147
1148 if query_projects_tenant_col(query, tenant_col)
1149 || self.cte_exposes_tenant_col(source_table, tenant_col)?
1150 {
1151 return Ok(Some(tenant_col.to_string()));
1152 }
1153
1154 Ok(None)
1155 }
1156
1157 fn cte_exposes_tenant_col(&self, cte_name: &str, tenant_col: &str) -> QailBuildResult<bool> {
1158 let Some(cte) = self
1159 .ctes
1160 .iter()
1161 .find(|cte| normalize_ident(&cte.name) == normalize_ident(cte_name))
1162 else {
1163 return Ok(false);
1164 };
1165 if !cte.columns.is_empty() {
1166 return Ok(cte
1167 .columns
1168 .iter()
1169 .any(|col| normalize_ident(col) == normalize_ident(tenant_col)));
1170 }
1171 let (base_table, _) = split_table_reference(&cte.base_query.table);
1172 if query_projects_tenant_col(&cte.base_query, tenant_col) {
1173 return Ok(true);
1174 }
1175 Ok(tenant_column_for(base_table)?
1176 .is_some_and(|col| normalize_ident(&col) == normalize_ident(tenant_col)))
1177 }
1178
1179 fn scope_merge_on_tenant_equality(
1180 &mut self,
1181 tenant_col: &str,
1182 target_col: String,
1183 source_col: Option<String>,
1184 ) {
1185 let Some(merge) = &mut self.merge else {
1186 return;
1187 };
1188 merge
1189 .on
1190 .retain(|cond| !condition_references_tenant_column(cond, tenant_col));
1191
1192 if let Some(source_col) = source_col {
1193 merge.on.push(Condition {
1194 left: Expr::Named(target_col),
1195 op: Operator::Eq,
1196 value: Value::Column(source_col),
1197 is_array_unnest: false,
1198 });
1199 }
1200 }
1201
1202 fn scope_merge_clause_conditions(
1203 &mut self,
1204 tenant_col: &str,
1205 target_condition: Condition,
1206 source_condition: Option<Condition>,
1207 ) {
1208 let Some(merge) = &mut self.merge else {
1209 return;
1210 };
1211
1212 for clause in &mut merge.clauses {
1213 clause
1214 .condition
1215 .retain(|cond| !condition_references_tenant_column(cond, tenant_col));
1216
1217 match clause.match_kind {
1218 MergeMatchKind::Matched | MergeMatchKind::NotMatchedBySource => {
1219 clause.condition.push(target_condition.clone());
1220 }
1221 MergeMatchKind::NotMatchedByTarget => {
1222 if let Some(condition) = &source_condition {
1223 clause.condition.push(condition.clone());
1224 }
1225 }
1226 }
1227 }
1228 }
1229
1230 fn scope_merge_insert_value(
1231 &mut self,
1232 tenant_col: &str,
1233 tenant_expr: Expr,
1234 ) -> QailBuildResult<()> {
1235 let Some(merge) = &mut self.merge else {
1236 return Ok(());
1237 };
1238
1239 for clause in &mut merge.clauses {
1240 let MergeAction::Insert { columns, values } = &mut clause.action else {
1241 continue;
1242 };
1243
1244 if columns.is_empty() {
1245 return Err(QailBuildError::RlsInsertRequiresExplicitColumns {
1246 table: self.table.clone(),
1247 tenant_column: tenant_col.to_string(),
1248 });
1249 }
1250
1251 if let Some(pos) = columns
1252 .iter()
1253 .position(|col| normalize_ident(col) == normalize_ident(tenant_col))
1254 {
1255 if let Some(value) = values.get_mut(pos) {
1256 *value = tenant_expr.clone();
1257 } else {
1258 values.push(tenant_expr.clone());
1259 }
1260 } else {
1261 columns.push(tenant_col.to_string());
1262 values.push(tenant_expr.clone());
1263 }
1264 }
1265
1266 Ok(())
1267 }
1268
1269 fn reject_merge_tenant_update_mutation(&self, tenant_col: &str) -> QailBuildResult<()> {
1270 let assigns_tenant = self
1271 .merge
1272 .as_ref()
1273 .is_some_and(|merge| {
1274 merge.clauses.iter().any(|clause| {
1275 matches!(&clause.action, MergeAction::Update { assignments }
1276 if assignments
1277 .iter()
1278 .any(|(column, _)| normalize_ident(column) == normalize_ident(tenant_col)))
1279 })
1280 });
1281
1282 if assigns_tenant {
1283 return Err(QailBuildError::RlsTenantColumnMutationDenied {
1284 table: self.table.clone(),
1285 tenant_column: tenant_col.to_string(),
1286 });
1287 }
1288
1289 Ok(())
1290 }
1291}
1292
1293#[cfg(test)]
1294mod tests {
1295 use super::*;
1296 use crate::ast::JoinKind;
1297 use crate::transpiler::ToSql;
1298
1299 fn seal_tenant_table(table: &str, column: &str) {
1308 crate::rls::init_scope_registries_from_tables(&[(table, column)], &[])
1309 .expect("boundary registration");
1310 }
1311
1312 fn seal_owner_table(table: &str, column: &str) {
1313 crate::rls::init_scope_registries_from_tables(&[], &[(table, column)])
1314 .expect("boundary registration");
1315 }
1316
1317 fn ensure_initialized() {
1320 seal_tenant_table("_rls_tests_sentinel", "tenant_id");
1321 }
1322
1323 #[test]
1326 fn owner_scope_injects_user_filter_on_get() {
1327 seal_owner_table("_rls_owner_listings", "seller_id");
1328 let ctx = RlsContext::user("u-1");
1329 let sql = Qail::get("_rls_owner_listings")
1330 .with_rls(&ctx)
1331 .expect("owner scope applies")
1332 .to_sql();
1333 assert!(sql.contains("seller_id = 'u-1'"), "{sql}");
1334 }
1335
1336 #[test]
1337 fn owner_scope_sets_payload_on_add_and_filters_set() {
1338 seal_owner_table("_rls_owner_posts", "author_id");
1339 let ctx = RlsContext::user("u-9");
1340 let add = Qail::add("_rls_owner_posts")
1341 .set_value("title", "hi")
1342 .with_rls(&ctx)
1343 .expect("add scoped")
1344 .to_sql();
1345 assert!(add.contains("'u-9'"), "{add}");
1346
1347 let set = Qail::set("_rls_owner_posts")
1348 .set_value("title", "edited")
1349 .with_rls(&ctx)
1350 .expect("set scoped")
1351 .to_sql();
1352 assert!(set.contains("author_id = 'u-9'"), "{set}");
1353
1354 let err = Qail::set("_rls_owner_posts")
1355 .set_value("author_id", "someone-else")
1356 .with_rls(&ctx)
1357 .expect_err("owner column mutation must be refused");
1358 assert!(matches!(
1359 err,
1360 QailBuildError::RlsTenantColumnMutationDenied { .. }
1361 ));
1362 }
1363
1364 #[test]
1365 fn tenant_and_owner_scopes_are_anded() {
1366 seal_tenant_table("_rls_both_notes", "tenant_id");
1367 seal_owner_table("_rls_both_notes", "user_id");
1368 let ctx = RlsContext::tenant("t-1").with_user("u-1");
1369 let sql = Qail::get("_rls_both_notes")
1370 .with_rls(&ctx)
1371 .expect("both scopes apply")
1372 .to_sql();
1373 assert!(sql.contains("tenant_id = 't-1'"), "{sql}");
1374 assert!(sql.contains("user_id = 'u-1'"), "{sql}");
1375 assert!(sql.contains(" AND "), "{sql}");
1376 }
1377
1378 #[test]
1379 fn registered_tenant_table_fails_closed_without_tenant() {
1380 seal_tenant_table("_rls_fc_orders", "tenant_id");
1381 let err = Qail::get("_rls_fc_orders")
1382 .with_rls(&RlsContext::user("u-1"))
1383 .expect_err("user-only context on a tenant table must not silently run unscoped");
1384 assert!(
1385 matches!(
1386 &err,
1387 QailBuildError::RlsScopeMissing {
1388 scope: "tenant",
1389 ..
1390 }
1391 ),
1392 "{err:?}"
1393 );
1394 let err = Qail::get("_rls_fc_orders")
1395 .with_rls(&RlsContext::empty())
1396 .expect_err("empty context must fail closed");
1397 assert!(matches!(err, QailBuildError::RlsScopeMissing { .. }));
1398 }
1399
1400 #[test]
1401 fn registry_lookup_failure_maps_to_registry_unavailable_not_unscoped() {
1402 assert!(
1406 matches!(super::tenant_column_for("_rls_probe_unreachable"), Ok(None)),
1407 "healthy registry → unregistered"
1408 );
1409
1410 let mapped =
1411 super::map_registry_lookup("orders", Err("tenant registry lock poisoned".to_string()))
1412 .expect_err("registry Err must never become Ok(None)");
1413 match mapped {
1414 QailBuildError::RlsRegistryUnavailable { table, reason } => {
1415 assert_eq!(table, "orders");
1416 assert!(reason.contains("poisoned"), "{reason}");
1417 }
1418 other => panic!("wrong variant: {other:?}"),
1419 }
1420 assert_eq!(
1421 super::map_registry_lookup("orders", Ok(Some("tenant_id".into()))).unwrap(),
1422 Some("tenant_id".to_string())
1423 );
1424 }
1425
1426 #[test]
1427 fn registered_owner_table_fails_closed_without_user() {
1428 seal_owner_table("_rls_fc_listings", "seller_id");
1429 let err = Qail::get("_rls_fc_listings")
1430 .with_rls(&RlsContext::tenant("t-1"))
1431 .expect_err("tenant-only context on an owner table must fail closed");
1432 assert!(
1433 matches!(&err, QailBuildError::RlsScopeMissing { scope: "user", .. }),
1434 "{err:?}"
1435 );
1436 assert!(
1437 Qail::get("_rls_fc_listings")
1438 .with_rls(&RlsContext::global())
1439 .is_err(),
1440 "global context carries no user"
1441 );
1442 }
1443
1444 #[test]
1445 fn owner_scope_rejects_merge_explicitly() {
1446 seal_owner_table("_rls_owner_merge", "owner_id");
1447 let ctx = RlsContext::user("u-1");
1448 let err = Qail::merge_into("_rls_owner_merge")
1449 .with_rls(&ctx)
1450 .expect_err("owner-scoped merge is unsupported, not silently unscoped");
1451 assert!(matches!(
1452 err,
1453 QailBuildError::RlsOwnerMergeUnsupported { .. }
1454 ));
1455 }
1456
1457 #[test]
1458 fn unregistered_table_with_user_only_context_stays_no_op() {
1459 ensure_initialized();
1460 let ctx = RlsContext::user("u-1");
1461 let sql = Qail::get("_rls_unregistered_reference")
1462 .with_rls(&ctx)
1463 .expect("no registry entry → untouched")
1464 .to_sql();
1465 assert!(!sql.contains("WHERE"), "{sql}");
1466 }
1467
1468 #[test]
1469 fn super_admin_bypasses_owner_scope() {
1470 seal_owner_table("_rls_owner_admin", "seller_id");
1471 let token = crate::rls::SuperAdminToken::for_system_process("owner_test");
1472 let sql = Qail::get("_rls_owner_admin")
1473 .with_rls(&RlsContext::super_admin(token))
1474 .expect("bypass")
1475 .to_sql();
1476 assert!(!sql.contains("seller_id"), "{sql}");
1477 }
1478
1479 #[test]
1482 fn owner_table_inside_cte_is_scoped_even_when_outer_is_unregistered() {
1483 seal_owner_table("_rls_cte_inner_listings", "seller_id");
1484 let ctx = RlsContext::user("u-1");
1485 let inner = Qail::get("_rls_cte_inner_listings");
1486 let sql = Qail::get("mine")
1487 .with("mine", inner)
1488 .with_rls(&ctx)
1489 .expect("outer unregistered, inner owner table must still be scoped")
1490 .to_sql();
1491 assert!(sql.contains("seller_id = 'u-1'"), "{sql}");
1492 }
1493
1494 #[test]
1495 fn tenant_table_inside_cte_fails_closed_under_user_only_context() {
1496 seal_tenant_table("_rls_cte_inner_orders", "tenant_id");
1497 let inner = Qail::get("_rls_cte_inner_orders");
1498 let err = Qail::get("mine")
1499 .with("mine", inner)
1500 .with_rls(&RlsContext::user("u-1"))
1501 .expect_err("inner tenant table must not run unscoped");
1502 assert!(matches!(
1503 err,
1504 QailBuildError::RlsScopeMissing {
1505 scope: "tenant",
1506 ..
1507 }
1508 ));
1509 }
1510
1511 #[test]
1512 fn joined_owner_relation_gets_on_predicate() {
1513 seal_owner_table("_rls_join_threads", "owner_id");
1514 let ctx = RlsContext::user("u-7");
1515 let sql = Qail::get("_rls_join_msgs")
1516 .join(
1517 JoinKind::Inner,
1518 "_rls_join_threads t",
1519 "_rls_join_msgs.thread_id",
1520 "t.id",
1521 )
1522 .with_rls(&ctx)
1523 .expect("join scoped")
1524 .to_sql();
1525 assert!(sql.contains("t.owner_id = 'u-7'"), "{sql}");
1526 }
1527
1528 #[test]
1529 fn joined_tenant_relation_under_global_ctx_gets_is_null() {
1530 seal_tenant_table("_rls_join_refs", "tenant_id");
1531 let sql = Qail::get("_rls_join_main")
1532 .join(
1533 JoinKind::Left,
1534 "_rls_join_refs",
1535 "_rls_join_main.ref_id",
1536 "_rls_join_refs.id",
1537 )
1538 .with_rls(&RlsContext::global())
1539 .expect("join scoped")
1540 .to_sql();
1541 assert!(sql.contains("_rls_join_refs.tenant_id IS NULL"), "{sql}");
1542 }
1543
1544 #[test]
1545 fn joined_registered_relation_via_full_join_is_refused() {
1546 seal_tenant_table("_rls_join_full", "tenant_id");
1547 let err = Qail::get("_rls_join_main2")
1548 .join(
1549 JoinKind::Full,
1550 "_rls_join_full",
1551 "a.id",
1552 "_rls_join_full.id",
1553 )
1554 .with_rls(&RlsContext::tenant("t-1"))
1555 .expect_err("FULL join cannot isolate");
1556 assert!(matches!(err, QailBuildError::RlsJoinKindUnsupported { .. }));
1557 }
1558
1559 #[test]
1560 fn joined_registered_relation_fails_closed_without_scope() {
1561 seal_owner_table("_rls_join_owned", "owner_id");
1562 let err = Qail::get("_rls_join_main3")
1563 .join(
1564 JoinKind::Inner,
1565 "_rls_join_owned",
1566 "a.id",
1567 "_rls_join_owned.id",
1568 )
1569 .with_rls(&RlsContext::tenant("t-1"))
1570 .expect_err("joined owner table needs a user");
1571 assert!(matches!(
1572 err,
1573 QailBuildError::RlsScopeMissing { scope: "user", .. }
1574 ));
1575 }
1576
1577 #[test]
1578 fn multiple_cross_joined_registered_relations_keep_every_predicate() {
1579 seal_tenant_table("_rls_cross_a", "tenant_id");
1580 seal_tenant_table("_rls_cross_b", "tenant_id");
1581 seal_tenant_table("_rls_cross_main", "tenant_id");
1582 let ctx = RlsContext::tenant("t1");
1583 let mut q = Qail::get("_rls_cross_main");
1584 for t in ["_rls_cross_a a", "_rls_cross_b b"] {
1585 q.joins.push(crate::ast::Join {
1586 table: t.to_string(),
1587 kind: JoinKind::Cross,
1588 on: None,
1589 on_true: true,
1590 });
1591 }
1592 let sql = q.with_rls(&ctx).expect("scoped").to_sql();
1593 assert!(sql.contains("a.tenant_id = 't1'"), "{sql}");
1594 assert!(sql.contains("b.tenant_id = 't1'"), "{sql}");
1595 assert!(sql.contains("tenant_id = 't1'"), "{sql}");
1596 assert_eq!(sql.matches("tenant_id = 't1'").count(), 3, "{sql}");
1597 }
1598
1599 #[test]
1600 fn multiple_cross_joined_registered_relations_keep_every_predicate_under_global() {
1601 seal_tenant_table("_rls_gcross_a", "tenant_id");
1602 seal_tenant_table("_rls_gcross_b", "tenant_id");
1603 seal_tenant_table("_rls_gcross_main", "tenant_id");
1604 let mut q = Qail::get("_rls_gcross_main");
1605 for t in ["_rls_gcross_a a", "_rls_gcross_b b"] {
1606 q.joins.push(crate::ast::Join {
1607 table: t.to_string(),
1608 kind: JoinKind::Cross,
1609 on: None,
1610 on_true: true,
1611 });
1612 }
1613 let sql = q.with_rls(&RlsContext::global()).expect("scoped").to_sql();
1614 assert!(sql.contains("a.tenant_id IS NULL"), "{sql}");
1615 assert!(sql.contains("b.tenant_id IS NULL"), "{sql}");
1616 assert_eq!(sql.matches("tenant_id IS NULL").count(), 3, "{sql}");
1617 }
1618
1619 #[test]
1620 fn user_supplied_unqualified_scope_filter_is_replaced_not_duplicated() {
1621 seal_tenant_table("_rls_dedup_orders", "tenant_id");
1622 let sql = Qail::get("_rls_dedup_orders")
1623 .eq("tenant_id", "spoofed")
1624 .with_rls(&RlsContext::tenant("t1"))
1625 .expect("scoped")
1626 .to_sql();
1627 assert!(!sql.contains("spoofed"), "{sql}");
1628 assert_eq!(sql.matches("tenant_id = 't1'").count(), 1, "{sql}");
1629 }
1630
1631 #[test]
1632 fn on_conflict_where_subquery_on_registered_table_is_scoped() {
1633 seal_tenant_table("_rls_ocw_target", "tenant_id");
1634 seal_tenant_table("_rls_ocw_inner", "tenant_id");
1635 let ctx = RlsContext::tenant("t1");
1636 let mut q = Qail::add("_rls_ocw_target")
1637 .columns(["id"])
1638 .values(vec![Value::String("x".into())])
1639 .on_conflict_update(
1640 &["id"],
1641 &[("touched", Expr::Named("EXCLUDED.touched".into()))],
1642 );
1643 q.on_conflict
1644 .as_mut()
1645 .unwrap()
1646 .where_conditions
1647 .push(Condition {
1648 left: Expr::Named("id".into()),
1649 op: Operator::In,
1650 value: Value::Subquery(Box::new(Qail::get("_rls_ocw_inner").columns(["id"]))),
1651 is_array_unnest: false,
1652 });
1653 let sql = q.with_rls(&ctx).expect("scoped").to_sql();
1654 assert!(
1655 sql.contains("WHERE _rls_ocw_inner.tenant_id = 't1'"),
1656 "nested relation inside ON CONFLICT WHERE must be scoped: {sql}"
1657 );
1658 }
1659
1660 #[test]
1661 fn on_conflict_do_update_is_gated_by_owner_scope() {
1662 seal_owner_table("_rls_upsert_devices", "user_id");
1663 let ctx = RlsContext::user("u-3");
1664 let sql = Qail::add("_rls_upsert_devices")
1665 .columns(["token", "platform"])
1666 .values(vec![
1667 Value::String("tok".into()),
1668 Value::String("ios".into()),
1669 ])
1670 .on_conflict_update(
1671 &["token"],
1672 &[("platform", Expr::Named("EXCLUDED.platform".into()))],
1673 )
1674 .with_rls(&ctx)
1675 .expect("upsert scoped")
1676 .to_sql();
1677 assert!(sql.contains("DO UPDATE SET"), "{sql}");
1678 assert!(
1679 sql.contains("WHERE _rls_upsert_devices.user_id = 'u-3'"),
1680 "{sql}"
1681 );
1682 assert!(sql.contains("'u-3'"), "{sql}");
1683 }
1684
1685 #[test]
1686 fn on_conflict_do_update_cannot_reassign_scope_column() {
1687 seal_tenant_table("_rls_upsert_tenanted", "tenant_id");
1688 let ctx = RlsContext::tenant("t-1");
1689 let err = Qail::add("_rls_upsert_tenanted")
1690 .columns(["id"])
1691 .values(vec![Value::String("x".into())])
1692 .on_conflict_update(
1693 &["id"],
1694 &[("tenant_id", Expr::Named("EXCLUDED.tenant_id".into()))],
1695 )
1696 .with_rls(&ctx)
1697 .expect_err("conflict update must not move the row to another tenant");
1698 assert!(matches!(
1699 err,
1700 QailBuildError::RlsTenantColumnMutationDenied { .. }
1701 ));
1702 }
1703
1704 #[test]
1705 fn on_conflict_do_nothing_is_untouched() {
1706 seal_tenant_table("_rls_upsert_nothing", "tenant_id");
1707 let sql = Qail::add("_rls_upsert_nothing")
1708 .columns(["id"])
1709 .values(vec![Value::String("x".into())])
1710 .on_conflict_nothing(&["id"])
1711 .with_rls(&RlsContext::tenant("t-1"))
1712 .expect("ok")
1713 .to_sql();
1714 assert!(sql.contains("DO NOTHING"), "{sql}");
1715 assert!(!sql.contains("DO NOTHING WHERE"), "{sql}");
1716 }
1717
1718 #[test]
1719 fn test_with_rls_injects_filter_on_get() {
1720 seal_tenant_table("_rls_get_orders", "tenant_id");
1721
1722 let ctx = RlsContext::tenant("t-123");
1723 let query = Qail::get("_rls_get_orders")
1724 .with_rls(&ctx)
1725 .expect("rls should apply");
1726
1727 let filter = query
1728 .cages
1729 .iter()
1730 .find(|c| matches!(c.kind, CageKind::Filter));
1731 assert!(filter.is_some(), "Expected filter cage");
1732
1733 let conditions = &filter.unwrap().conditions;
1734 assert!(
1735 conditions.iter().any(|c| {
1736 matches!(&c.left, Expr::Named(n) if n.ends_with("tenant_id"))
1737 && matches!(&c.value, Value::String(v) if v == "t-123")
1738 }),
1739 "Expected tenant_id = 't-123' condition"
1740 );
1741 }
1742
1743 #[test]
1744 fn test_with_rls_resolves_primary_table_alias_on_get() {
1745 seal_tenant_table("_rls_alias_get_orders", "tenant_id");
1746
1747 let ctx = RlsContext::tenant("tenant-alias");
1748 let query = Qail::get("_rls_alias_get_orders")
1749 .table_alias("o")
1750 .with_rls(&ctx)
1751 .expect("rls should apply through primary table alias");
1752
1753 let sql = query.to_sql();
1754 assert!(
1755 sql.contains("FROM _rls_alias_get_orders o"),
1756 "expected aliased FROM table: {sql}"
1757 );
1758 assert!(
1759 sql.contains("WHERE o.tenant_id = 'tenant-alias'"),
1760 "RLS tenant filter should use the primary alias: {sql}"
1761 );
1762 }
1763
1764 #[test]
1765 fn test_with_rls_injects_payload_on_add() {
1766 seal_tenant_table("_rls_add_orders", "tenant_id");
1767
1768 let ctx = RlsContext::tenant("t-456");
1769 let query = Qail::add("_rls_add_orders")
1770 .set_value("total", 100)
1771 .with_rls(&ctx)
1772 .expect("rls should apply");
1773
1774 let payload = query
1775 .cages
1776 .iter()
1777 .find(|c| matches!(c.kind, CageKind::Payload));
1778 assert!(payload.is_some(), "Expected payload cage");
1779
1780 let conditions = &payload.unwrap().conditions;
1781 assert!(
1782 conditions.iter().any(|c| {
1783 matches!(&c.left, Expr::Named(n) if n.ends_with("tenant_id"))
1784 && matches!(&c.value, Value::String(v) if v == "t-456")
1785 }),
1786 "Expected tenant_id = 't-456' in payload"
1787 );
1788 }
1789
1790 #[test]
1791 fn test_with_rls_noop_for_super_admin() {
1792 seal_tenant_table("_rls_admin_orders", "tenant_id");
1793
1794 let token = crate::rls::SuperAdminToken::for_system_process("test_super_admin_noop");
1795 let ctx = RlsContext::super_admin(token);
1796 let query = Qail::get("_rls_admin_orders")
1797 .with_rls(&ctx)
1798 .expect("super admin rls should no-op");
1799
1800 let filter = query
1801 .cages
1802 .iter()
1803 .find(|c| matches!(c.kind, CageKind::Filter));
1804 assert!(filter.is_none(), "Super admin should not have filter");
1805 }
1806
1807 #[test]
1808 fn test_with_rls_noop_for_unregistered_table() {
1809 ensure_initialized();
1810 let ctx = RlsContext::tenant("t-789");
1811 let query = Qail::get("_rls_unreg_migrations")
1812 .with_rls(&ctx)
1813 .expect("unregistered table rls should no-op");
1814
1815 let filter = query
1816 .cages
1817 .iter()
1818 .find(|c| matches!(c.kind, CageKind::Filter));
1819 assert!(
1820 filter.is_none(),
1821 "Unregistered table should not have filter"
1822 );
1823 }
1824
1825 #[test]
1826 fn test_with_rls_noop_for_ddl() {
1827 seal_tenant_table("_rls_ddl_orders", "tenant_id");
1828
1829 let ctx = RlsContext::tenant("t-000");
1830 let query = Qail {
1831 action: Action::Make,
1832 table: "_rls_ddl_orders".to_string(),
1833 ..Default::default()
1834 };
1835 let query = query.with_rls(&ctx).expect("ddl rls should no-op");
1836
1837 assert!(query.cages.is_empty(), "DDL should not inject cages");
1838 }
1839
1840 #[test]
1841 fn test_with_rls_appends_to_existing_filter() {
1842 seal_tenant_table("_rls_merge_orders", "tenant_id");
1843
1844 let ctx = RlsContext::tenant("t-merge");
1845 let query = Qail::get("_rls_merge_orders")
1846 .filter("status", Operator::Eq, "active")
1847 .with_rls(&ctx)
1848 .expect("rls should apply");
1849
1850 let filters: Vec<_> = query
1851 .cages
1852 .iter()
1853 .filter(|c| matches!(c.kind, CageKind::Filter))
1854 .collect();
1855 assert_eq!(filters.len(), 1, "Should merge into one filter cage");
1856 assert_eq!(
1857 filters[0].conditions.len(),
1858 2,
1859 "Should have 2 conditions: status + tenant_id"
1860 );
1861 }
1862
1863 #[test]
1864 fn test_with_rls_does_not_merge_tenant_scope_into_or_filter_cage() {
1865 seal_tenant_table("_rls_or_orders", "tenant_id");
1866
1867 let ctx = RlsContext::tenant("t-or");
1868 let query = Qail::get("_rls_or_orders")
1869 .or_filter("status", Operator::Eq, "active")
1870 .or_filter("status", Operator::Eq, "pending")
1871 .with_rls(&ctx)
1872 .expect("rls should apply");
1873
1874 let or_filter = query
1875 .cages
1876 .iter()
1877 .find(|c| matches!(c.kind, CageKind::Filter) && c.logical_op == LogicalOp::Or)
1878 .expect("Expected OR filter cage");
1879 assert_eq!(
1880 or_filter.conditions.len(),
1881 2,
1882 "OR cage should keep only OR terms"
1883 );
1884 assert!(
1885 !or_filter
1886 .conditions
1887 .iter()
1888 .any(|c| is_tenant_column_condition(c, "tenant_id")),
1889 "tenant scope must not be injected into OR cage"
1890 );
1891
1892 let and_filter = query
1893 .cages
1894 .iter()
1895 .find(|c| matches!(c.kind, CageKind::Filter) && c.logical_op == LogicalOp::And)
1896 .expect("Expected AND filter cage for tenant scope");
1897 assert!(
1898 and_filter
1899 .conditions
1900 .iter()
1901 .any(|c| is_tenant_column_condition(c, "tenant_id")),
1902 "tenant scope must be enforced via AND cage"
1903 );
1904
1905 let sql = query.to_sql();
1906 assert!(
1907 sql.contains("tenant_id = 't-or'"),
1908 "Expected tenant scope in SQL: {sql}"
1909 );
1910 assert!(
1911 !sql.contains("OR tenant_id = 't-or'"),
1912 "tenant scope must not be OR-ed with user conditions: {sql}"
1913 );
1914 }
1915
1916 #[test]
1917 fn test_with_rls_on_set_injects_filter() {
1918 seal_tenant_table("_rls_set_orders", "tenant_id");
1919
1920 let ctx = RlsContext::tenant("t-set");
1921 let query = Qail::set("_rls_set_orders")
1922 .set_value("status", "shipped")
1923 .with_rls(&ctx)
1924 .expect("rls should apply");
1925
1926 let filter = query
1927 .cages
1928 .iter()
1929 .find(|c| matches!(c.kind, CageKind::Filter));
1930 assert!(filter.is_some(), "SET should inject filter");
1931
1932 let conditions = &filter.unwrap().conditions;
1933 assert!(
1934 conditions
1935 .iter()
1936 .any(|c| { matches!(&c.left, Expr::Named(n) if n.ends_with("tenant_id")) }),
1937 "Expected tenant_id filter on SET"
1938 );
1939 }
1940
1941 #[test]
1942 fn test_with_rls_resolves_primary_table_alias_on_set() {
1943 seal_tenant_table("_rls_alias_set_orders", "tenant_id");
1944
1945 let ctx = RlsContext::tenant("tenant-set-alias");
1946 let query = Qail::set("_rls_alias_set_orders")
1947 .table_alias("o")
1948 .set_value("status", "paid")
1949 .with_rls(&ctx)
1950 .expect("rls should apply through UPDATE alias");
1951
1952 let sql = query.to_sql();
1953 assert!(
1954 sql.contains("UPDATE _rls_alias_set_orders o SET status = 'paid'"),
1955 "expected aliased UPDATE target: {sql}"
1956 );
1957 assert!(
1958 sql.contains("WHERE o.tenant_id = 'tenant-set-alias'"),
1959 "RLS tenant filter should use the UPDATE alias: {sql}"
1960 );
1961 }
1962
1963 #[test]
1964 fn test_with_rls_on_set_rejects_tenant_column_update() {
1965 seal_tenant_table("_rls_set_tenant_rewrite_orders", "tenant_id");
1966
1967 let ctx = RlsContext::tenant("tenant-a");
1968 let err = Qail::set("_rls_set_tenant_rewrite_orders")
1969 .set_value("tenant_id", "tenant-b")
1970 .with_rls(&ctx)
1971 .expect_err("tenant column updates must fail closed");
1972
1973 assert!(err.to_string().contains("tenant column mutation"));
1974 }
1975
1976 #[test]
1977 fn test_with_rls_injects_filter_on_read_like_actions() {
1978 let actions = [
1979 (Action::Cnt, "_rls_cnt_orders"),
1980 (Action::Export, "_rls_export_orders"),
1981 (Action::Search, "_rls_search_vectors"),
1982 (Action::Scroll, "_rls_scroll_vectors"),
1983 ];
1984
1985 for (action, table) in actions {
1986 seal_tenant_table(table, "tenant_id");
1987
1988 let ctx = RlsContext::tenant("tenant-read-like");
1989 let query = Qail {
1990 action,
1991 table: table.to_string(),
1992 ..Default::default()
1993 }
1994 .with_rls(&ctx)
1995 .expect("read-like action should apply RLS");
1996
1997 let filter = query
1998 .cages
1999 .iter()
2000 .find(|c| matches!(c.kind, CageKind::Filter))
2001 .expect("Expected filter cage");
2002
2003 assert!(
2004 filter.conditions.iter().any(|c| {
2005 matches!(&c.left, Expr::Named(n) if n.ends_with("tenant_id"))
2006 && matches!(&c.value, Value::String(v) if v == "tenant-read-like")
2007 }),
2008 "Expected tenant filter on {action:?}"
2009 );
2010 }
2011 }
2012
2013 #[test]
2014 fn test_with_rls_empty_context_fails_closed_on_tenant_table() {
2015 seal_tenant_table("_rls_noops_orders", "tenant_id");
2016
2017 let ctx = RlsContext::user("u-no-tenant");
2020 let err = Qail::get("_rls_noops_orders")
2021 .with_rls(&ctx)
2022 .expect_err("missing tenant on a registered table must fail closed");
2023 assert!(
2024 matches!(
2025 &err,
2026 QailBuildError::RlsScopeMissing {
2027 scope: "tenant",
2028 ..
2029 }
2030 ),
2031 "{err:?}"
2032 );
2033 }
2034
2035 #[test]
2036 fn test_with_rls_global_injects_is_null_filter() {
2037 seal_tenant_table("_rls_global_get_orders", "tenant_id");
2038
2039 let ctx = RlsContext::global();
2040 let query = Qail::get("_rls_global_get_orders")
2041 .with_rls(&ctx)
2042 .expect("global rls should apply");
2043
2044 let filter = query
2045 .cages
2046 .iter()
2047 .find(|c| matches!(c.kind, CageKind::Filter));
2048 assert!(filter.is_some(), "Expected filter cage for global scope");
2049
2050 let conditions = &filter.expect("filter cage").conditions;
2051 assert!(
2052 conditions.iter().any(|c| {
2053 matches!(&c.left, Expr::Named(n) if n.ends_with("tenant_id"))
2054 && c.op == Operator::IsNull
2055 && matches!(&c.value, Value::Null)
2056 }),
2057 "Expected tenant_id IS NULL condition"
2058 );
2059 }
2060
2061 #[test]
2062 fn test_with_rls_global_injects_null_payload_on_add() {
2063 seal_tenant_table("_rls_global_add_catalog", "tenant_id");
2064
2065 let ctx = RlsContext::global();
2066 let query = Qail::add("_rls_global_add_catalog")
2067 .set_value("name", "item")
2068 .with_rls(&ctx)
2069 .expect("global rls should apply");
2070
2071 let payload = query
2072 .cages
2073 .iter()
2074 .find(|c| matches!(c.kind, CageKind::Payload));
2075 assert!(payload.is_some(), "Expected payload cage");
2076
2077 let conditions = &payload.expect("payload cage").conditions;
2078 assert!(
2079 conditions.iter().any(|c| {
2080 matches!(&c.left, Expr::Named(n) if n.ends_with("tenant_id"))
2081 && matches!(&c.value, Value::Null)
2082 }),
2083 "Expected tenant_id = NULL in payload"
2084 );
2085 }
2086
2087 #[test]
2088 fn test_with_rls_scopes_expression_subquery() {
2089 seal_tenant_table("_rls_expr_orders", "tenant_id");
2090 seal_tenant_table("_rls_expr_invoices", "tenant_id");
2091
2092 let ctx = RlsContext::tenant("tenant-expr");
2093 let mut query = Qail::get("_rls_expr_orders").columns(["id"]);
2094 query.columns.push(Expr::Subquery {
2095 query: Box::new(Qail::get("_rls_expr_invoices").columns(["total"])),
2096 alias: Some("invoice_total".to_string()),
2097 });
2098
2099 let query = query.with_rls(&ctx).expect("rls should apply");
2100 let subquery = query
2101 .columns
2102 .iter()
2103 .find_map(|expr| {
2104 if let Expr::Subquery { query, .. } = expr {
2105 Some(query)
2106 } else {
2107 None
2108 }
2109 })
2110 .expect("expression subquery");
2111
2112 assert!(subquery.cages.iter().any(|cage| {
2113 matches!(cage.kind, CageKind::Filter) && cage.conditions.iter().any(|condition| {
2114 matches!(&condition.left, Expr::Named(name) if name.ends_with("tenant_id"))
2115 && matches!(&condition.value, Value::String(value) if value == "tenant-expr")
2116 })
2117 }));
2118 }
2119
2120 #[test]
2121 fn test_with_rls_scopes_condition_value_subquery() {
2122 seal_tenant_table("_rls_condition_orders", "tenant_id");
2123 seal_tenant_table("_rls_condition_invoices", "tenant_id");
2124
2125 let ctx = RlsContext::tenant("tenant-condition");
2126 let query = Qail::get("_rls_condition_orders")
2127 .filter(
2128 "id",
2129 Operator::In,
2130 Value::Subquery(Box::new(
2131 Qail::get("_rls_condition_invoices").columns(["order_id"]),
2132 )),
2133 )
2134 .with_rls(&ctx)
2135 .expect("rls should apply");
2136
2137 let subquery = query
2138 .cages
2139 .iter()
2140 .flat_map(|cage| &cage.conditions)
2141 .find_map(|condition| {
2142 if let Value::Subquery(query) = &condition.value {
2143 Some(query)
2144 } else {
2145 None
2146 }
2147 })
2148 .expect("condition subquery");
2149
2150 assert!(subquery.cages.iter().any(|cage| {
2151 matches!(cage.kind, CageKind::Filter)
2152 && cage.conditions.iter().any(|condition| {
2153 matches!(&condition.left, Expr::Named(name) if name.ends_with("tenant_id"))
2154 && matches!(&condition.value, Value::String(value) if value == "tenant-condition")
2155 })
2156 }));
2157 }
2158
2159 #[test]
2160 fn test_with_rls_scopes_merge_on_and_insert_action() {
2161 seal_tenant_table("_rls_merge_upsert_orders", "tenant_id");
2162 seal_tenant_table("_rls_merge_source_orders", "tenant_id");
2163
2164 let ctx = RlsContext::tenant("tenant-merge");
2165 let query = Qail::merge_into("_rls_merge_upsert_orders")
2166 .target_alias("t")
2167 .using_table_as("_rls_merge_source_orders", "s")
2168 .merge_on_column("t.id", Operator::Eq, "s.id")
2169 .when_matched_update(&[("status", Expr::Named("s.status".to_string()))])
2170 .when_not_matched_insert(
2171 &["id", "status"],
2172 &[
2173 Expr::Named("s.id".to_string()),
2174 Expr::Named("s.status".to_string()),
2175 ],
2176 )
2177 .with_rls(&ctx)
2178 .expect("merge rls should apply");
2179
2180 let sql = query.to_sql();
2181 assert!(
2182 sql.contains("ON t.id = s.id AND t.tenant_id = s.tenant_id"),
2183 "MERGE ON must preserve target/source tenant equality: {sql}"
2184 );
2185 assert!(
2186 sql.contains("WHEN MATCHED AND t.tenant_id = 'tenant-merge' THEN UPDATE"),
2187 "MERGE matched branch must be target-tenant scoped: {sql}"
2188 );
2189 assert!(
2190 sql.contains("WHEN NOT MATCHED BY TARGET AND s.tenant_id = 'tenant-merge' THEN INSERT"),
2191 "MERGE insert branch must be source-tenant scoped: {sql}"
2192 );
2193 assert!(
2194 sql.contains("INSERT (id, status, tenant_id) VALUES (s.id, s.status, 'tenant-merge')"),
2195 "MERGE insert branch must include tenant value: {sql}"
2196 );
2197 }
2198
2199 #[test]
2200 fn test_with_rls_scopes_merge_inline_source_alias() {
2201 seal_tenant_table("_rls_merge_inline_target_orders", "tenant_id");
2202 seal_tenant_table("_rls_merge_inline_source_orders", "tenant_id");
2203
2204 let ctx = RlsContext::tenant("tenant-inline");
2205 let query = Qail::merge_into("_rls_merge_inline_target_orders")
2206 .target_alias("t")
2207 .using_table("_rls_merge_inline_source_orders s")
2208 .merge_on_column("t.id", Operator::Eq, "s.id")
2209 .when_matched_update(&[("status", Expr::Named("s.status".to_string()))])
2210 .when_not_matched_insert(
2211 &["id", "status"],
2212 &[
2213 Expr::Named("s.id".to_string()),
2214 Expr::Named("s.status".to_string()),
2215 ],
2216 )
2217 .with_rls(&ctx)
2218 .expect("merge rls should apply through inline source alias");
2219
2220 let sql = query.to_sql();
2221 assert!(
2222 sql.contains("USING _rls_merge_inline_source_orders s"),
2223 "MERGE source should keep inline alias: {sql}"
2224 );
2225 assert!(
2226 sql.contains("ON t.id = s.id AND t.tenant_id = s.tenant_id"),
2227 "MERGE ON must scope inline source alias tenant equality: {sql}"
2228 );
2229 assert!(
2230 sql.contains(
2231 "WHEN NOT MATCHED BY TARGET AND s.tenant_id = 'tenant-inline' THEN INSERT"
2232 ),
2233 "MERGE insert branch must scope inline source alias: {sql}"
2234 );
2235 }
2236
2237 #[test]
2238 fn test_with_rls_scopes_merge_query_source() {
2239 seal_tenant_table("_rls_merge_query_target_orders", "tenant_id");
2240 seal_tenant_table("_rls_merge_query_source_orders", "tenant_id");
2241
2242 let ctx = RlsContext::tenant("tenant-query");
2243 let source = Qail::get("_rls_merge_query_source_orders").columns(["id", "status"]);
2244 let query = Qail::merge_into("_rls_merge_query_target_orders")
2245 .target_alias("t")
2246 .using_query_as(source, "s")
2247 .merge_on_column("t.id", Operator::Eq, "s.id")
2248 .when_not_matched_insert(
2249 &["id", "status"],
2250 &[
2251 Expr::Named("s.id".to_string()),
2252 Expr::Named("s.status".to_string()),
2253 ],
2254 )
2255 .with_rls(&ctx)
2256 .expect("merge rls should apply");
2257
2258 let merge = query.merge.as_ref().expect("merge spec");
2259 let MergeSource::Query {
2260 query: source_query,
2261 ..
2262 } = &merge.source
2263 else {
2264 panic!("expected query source");
2265 };
2266 assert!(
2267 source_query.cages.iter().any(|cage| {
2268 matches!(cage.kind, CageKind::Filter)
2269 && cage.conditions.iter().any(|condition| {
2270 matches!(&condition.left, Expr::Named(name) if name.ends_with("tenant_id"))
2271 && condition.op == Operator::Eq
2272 && matches!(&condition.value, Value::String(value) if value == "tenant-query")
2273 })
2274 }),
2275 "MERGE query source must be tenant-scoped"
2276 );
2277 assert!(
2278 source_query
2279 .columns
2280 .iter()
2281 .any(|expr| matches!(expr, Expr::Named(name) if name.ends_with("tenant_id"))),
2282 "MERGE query source must project tenant_id for ON classification"
2283 );
2284
2285 let sql = query.to_sql();
2286 assert!(
2287 sql.contains("ON t.id = s.id AND t.tenant_id = s.tenant_id"),
2288 "MERGE query source ON must include target/source tenant equality: {sql}"
2289 );
2290 assert!(
2291 sql.contains("WHEN NOT MATCHED BY TARGET AND s.tenant_id = 'tenant-query' THEN INSERT"),
2292 "MERGE query source insert branch must be source-tenant scoped: {sql}"
2293 );
2294 }
2295
2296 #[test]
2297 fn test_with_rls_scopes_aliased_merge_query_source_table() {
2298 seal_tenant_table("_rls_merge_query_alias_target_orders", "tenant_id");
2299 seal_tenant_table("_rls_merge_query_alias_source_orders", "tenant_id");
2300
2301 let ctx = RlsContext::tenant("tenant-query-alias");
2302 let source = Qail::get("_rls_merge_query_alias_source_orders")
2303 .table_alias("base")
2304 .columns(["id", "status"]);
2305 let query = Qail::merge_into("_rls_merge_query_alias_target_orders")
2306 .target_alias("t")
2307 .using_query_as(source, "s")
2308 .merge_on_column("t.id", Operator::Eq, "s.id")
2309 .when_matched_update(&[("status", Expr::Named("s.status".to_string()))])
2310 .when_not_matched_insert(
2311 &["id", "status"],
2312 &[
2313 Expr::Named("s.id".to_string()),
2314 Expr::Named("s.status".to_string()),
2315 ],
2316 )
2317 .with_rls(&ctx)
2318 .expect("merge rls should apply through aliased source query table");
2319
2320 let sql = query.to_sql();
2321 assert!(
2322 sql.contains("FROM _rls_merge_query_alias_source_orders base WHERE base.tenant_id = 'tenant-query-alias'"),
2323 "MERGE source query should be scoped through its base-table alias: {sql}"
2324 );
2325 assert!(
2326 sql.contains("ON t.id = s.id AND t.tenant_id = s.tenant_id"),
2327 "MERGE query source ON must include outer source tenant equality: {sql}"
2328 );
2329 }
2330
2331 #[test]
2332 fn test_with_rls_scopes_cte_backed_merge_source() {
2333 seal_tenant_table("_rls_merge_cte_target_orders", "tenant_id");
2334 seal_tenant_table("_rls_merge_cte_source_orders", "tenant_id");
2335
2336 let ctx = RlsContext::tenant("tenant-cte");
2337 let incoming =
2338 Qail::get("_rls_merge_cte_source_orders").columns(["id", "status", "tenant_id"]);
2339 let source_query = Qail::get("incoming").columns(["id", "status", "tenant_id"]);
2340 let query = Qail::merge_into("_rls_merge_cte_target_orders")
2341 .target_alias("t")
2342 .with("incoming", incoming)
2343 .using_query_as(source_query, "s")
2344 .merge_on_column("t.id", Operator::Eq, "s.id")
2345 .when_matched_update(&[("status", Expr::Named("s.status".to_string()))])
2346 .when_not_matched_insert(
2347 &["id", "status"],
2348 &[
2349 Expr::Named("s.id".to_string()),
2350 Expr::Named("s.status".to_string()),
2351 ],
2352 )
2353 .with_rls(&ctx)
2354 .expect("merge rls should apply");
2355
2356 let cte = query.ctes.first().expect("incoming CTE");
2357 assert!(
2358 cte.base_query.cages.iter().any(|cage| {
2359 matches!(cage.kind, CageKind::Filter) && cage.conditions.iter().any(|condition| {
2360 matches!(&condition.left, Expr::Named(name) if name.ends_with("tenant_id"))
2361 && condition.op == Operator::Eq
2362 && matches!(&condition.value, Value::String(value) if value == "tenant-cte")
2363 })
2364 }),
2365 "outer MERGE CTE source must be tenant-scoped"
2366 );
2367
2368 let sql = query.to_sql();
2369 assert!(
2370 sql.contains("ON t.id = s.id AND t.tenant_id = s.tenant_id"),
2371 "CTE-backed MERGE query source ON must include tenant equality: {sql}"
2372 );
2373 assert!(
2374 sql.contains("WHEN NOT MATCHED BY TARGET AND s.tenant_id = 'tenant-cte' THEN INSERT"),
2375 "CTE-backed MERGE insert branch must be source-tenant scoped: {sql}"
2376 );
2377 }
2378
2379 #[test]
2380 fn test_with_rls_scopes_cte_alias_queries_before_table_lookup() {
2381 seal_tenant_table("_rls_cte_alias_source_orders", "tenant_id");
2382
2383 let ctx = RlsContext::tenant("tenant-alias");
2384 let query = Qail::get("incoming")
2385 .with(
2386 "incoming",
2387 Qail::get("_rls_cte_alias_source_orders").columns(["id", "tenant_id"]),
2388 )
2389 .with_rls(&ctx)
2390 .expect("cte alias query should still scope registered CTE body");
2391
2392 let cte = query.ctes.first().expect("incoming CTE");
2393 assert!(
2394 cte.base_query.cages.iter().any(|cage| {
2395 matches!(cage.kind, CageKind::Filter)
2396 && cage.conditions.iter().any(|condition| {
2397 matches!(&condition.left, Expr::Named(name) if name.ends_with("tenant_id"))
2398 && matches!(&condition.value, Value::String(value) if value == "tenant-alias")
2399 })
2400 }),
2401 "registered CTE bodies must be scoped even when outer table is a CTE alias"
2402 );
2403 }
2404
2405 #[test]
2406 fn test_with_rls_rejects_merge_tenant_column_update() {
2407 seal_tenant_table("_rls_merge_tenant_rewrite_orders", "tenant_id");
2408 seal_tenant_table("_rls_merge_tenant_rewrite_source", "tenant_id");
2409
2410 let ctx = RlsContext::tenant("tenant-a");
2411 let err = Qail::merge_into("_rls_merge_tenant_rewrite_orders")
2412 .using_table_as("_rls_merge_tenant_rewrite_source", "s")
2413 .merge_on_column("_rls_merge_tenant_rewrite_orders.id", Operator::Eq, "s.id")
2414 .when_matched_update(&[("tenant_id", Expr::Named("s.tenant_id".to_string()))])
2415 .with_rls(&ctx)
2416 .expect_err("MERGE tenant column updates must fail closed");
2417
2418 assert!(err.to_string().contains("tenant column mutation"));
2419 }
2420
2421 #[test]
2422 fn test_with_rls_global_scopes_merge_query_source() {
2423 seal_tenant_table("_rls_global_merge_query_target", "tenant_id");
2424 seal_tenant_table("_rls_global_merge_query_source", "tenant_id");
2425
2426 let source = Qail::get("_rls_global_merge_query_source").columns(["id", "name"]);
2427 let query = Qail::merge_into("_rls_global_merge_query_target")
2428 .using_query_as(source, "s")
2429 .merge_on_column("_rls_global_merge_query_target.id", Operator::Eq, "s.id")
2430 .when_not_matched_insert(
2431 &["id", "name"],
2432 &[
2433 Expr::Named("s.id".to_string()),
2434 Expr::Named("s.name".to_string()),
2435 ],
2436 )
2437 .with_rls(&RlsContext::global())
2438 .expect("global merge rls should apply");
2439
2440 let merge = query.merge.as_ref().expect("merge spec");
2441 let MergeSource::Query {
2442 query: source_query,
2443 ..
2444 } = &merge.source
2445 else {
2446 panic!("expected query source");
2447 };
2448 assert!(
2449 source_query.cages.iter().any(|cage| {
2450 matches!(cage.kind, CageKind::Filter)
2451 && cage.conditions.iter().any(|condition| {
2452 matches!(&condition.left, Expr::Named(name) if name.ends_with("tenant_id"))
2453 && condition.op == Operator::IsNull
2454 && matches!(condition.value, Value::Null)
2455 })
2456 }),
2457 "global MERGE query source must be scoped to NULL tenant rows"
2458 );
2459
2460 let sql = query.to_sql();
2461 assert!(
2462 sql.contains("ON _rls_global_merge_query_target.id = s.id AND _rls_global_merge_query_target.tenant_id = s.tenant_id"),
2463 "global MERGE query source ON must include target/source tenant equality: {sql}"
2464 );
2465 assert!(
2466 sql.contains("WHEN NOT MATCHED BY TARGET AND s.tenant_id IS NULL THEN INSERT"),
2467 "global MERGE query source insert branch must be source-tenant scoped: {sql}"
2468 );
2469 }
2470
2471 #[test]
2472 fn test_with_rls_rejects_merge_query_source_without_tenant_projection() {
2473 seal_tenant_table("_rls_merge_aggregate_target", "tenant_id");
2474 seal_tenant_table("_rls_merge_aggregate_source", "tenant_id");
2475
2476 let mut source = Qail::get("_rls_merge_aggregate_source");
2477 source.columns.push(Expr::Aggregate {
2478 col: "*".to_string(),
2479 func: crate::ast::AggregateFunc::Count,
2480 distinct: false,
2481 filter: None,
2482 alias: Some("total".to_string()),
2483 });
2484
2485 let err = Qail::merge_into("_rls_merge_aggregate_target")
2486 .target_alias("t")
2487 .using_query_as(source, "s")
2488 .merge_on_column("t.id", Operator::Eq, "s.id")
2489 .when_not_matched_insert(&["id"], &[Expr::Named("s.id".to_string())])
2490 .with_rls(&RlsContext::tenant("tenant-aggregate"))
2491 .expect_err("aggregate query source without tenant projection must fail closed");
2492
2493 assert!(err.to_string().contains("MERGE query sources"));
2494 }
2495
2496 #[test]
2497 fn test_with_rls_scopes_merge_by_source_delete_without_target_only_on_predicate() {
2498 seal_tenant_table("_rls_merge_prune_orders", "tenant_id");
2499 seal_tenant_table("_rls_merge_prune_source_orders", "tenant_id");
2500
2501 let ctx = RlsContext::tenant("tenant-prune");
2502 let query = Qail::merge_into("_rls_merge_prune_orders")
2503 .target_alias("t")
2504 .using_table_as("_rls_merge_prune_source_orders", "s")
2505 .merge_on_column("t.id", Operator::Eq, "s.id")
2506 .when_not_matched_by_source_delete()
2507 .with_rls(&ctx)
2508 .expect("merge rls should apply");
2509
2510 let sql = query.to_sql();
2511 assert!(
2512 sql.contains("ON t.id = s.id AND t.tenant_id = s.tenant_id"),
2513 "MERGE ON should use target/source tenant equality, not a target-only literal: {sql}"
2514 );
2515 assert!(
2516 sql.contains("WHEN NOT MATCHED BY SOURCE AND t.tenant_id = 'tenant-prune' THEN DELETE"),
2517 "BY SOURCE delete must be target-tenant scoped in the WHEN branch: {sql}"
2518 );
2519 assert!(
2520 !sql.contains("ON t.id = s.id AND t.tenant_id = 'tenant-prune'"),
2521 "target-only tenant predicates in ON can misclassify BY SOURCE rows: {sql}"
2522 );
2523 }
2524
2525 #[test]
2526 fn test_with_rls_global_scopes_merge_to_null_tenant() {
2527 seal_tenant_table("_rls_global_merge_catalog", "tenant_id");
2528 seal_tenant_table("_rls_global_merge_source", "tenant_id");
2529
2530 let query = Qail::merge_into("_rls_global_merge_catalog")
2531 .using_table_as("_rls_global_merge_source", "s")
2532 .merge_on_column("_rls_global_merge_catalog.id", Operator::Eq, "s.id")
2533 .when_not_matched_insert(
2534 &["id", "name"],
2535 &[
2536 Expr::Named("s.id".to_string()),
2537 Expr::Named("s.name".to_string()),
2538 ],
2539 )
2540 .with_rls(&RlsContext::global())
2541 .expect("global merge rls should apply");
2542
2543 let sql = query.to_sql();
2544 assert!(
2545 sql.contains(
2546 "ON _rls_global_merge_catalog.id = s.id AND _rls_global_merge_catalog.tenant_id = s.tenant_id"
2547 ),
2548 "global MERGE ON must preserve target/source tenant equality: {sql}"
2549 );
2550 assert!(
2551 sql.contains("WHEN NOT MATCHED BY TARGET AND s.tenant_id IS NULL THEN INSERT"),
2552 "global MERGE insert branch must be source-null scoped: {sql}"
2553 );
2554 assert!(
2555 sql.contains("INSERT (id, name, tenant_id) VALUES (s.id, s.name, NULL)"),
2556 "global MERGE insert branch must include NULL tenant: {sql}"
2557 );
2558 }
2559
2560 #[test]
2561 fn test_with_rls_is_idempotent_on_filter_scope() {
2562 seal_tenant_table("_rls_idempotent_get_orders", "tenant_id");
2563
2564 let ctx = RlsContext::tenant("t-idempotent");
2565 let query = Qail::get("_rls_idempotent_get_orders")
2566 .with_rls(&ctx)
2567 .expect("rls should apply")
2568 .with_rls(&ctx);
2569 let query = query.expect("rls should remain idempotent");
2570
2571 let filter = query
2572 .cages
2573 .iter()
2574 .find(|c| matches!(c.kind, CageKind::Filter))
2575 .expect("filter cage");
2576
2577 let tenant_matches = filter
2578 .conditions
2579 .iter()
2580 .filter(|c| matches!(&c.left, Expr::Named(n) if n.ends_with("tenant_id")))
2581 .count();
2582 assert_eq!(tenant_matches, 1, "tenant scope should not duplicate");
2583 }
2584
2585 #[test]
2586 fn test_with_rls_add_positional_payload_aligns_insert_columns() {
2587 seal_tenant_table("_rls_positional_add_orders", "tenant_id");
2588
2589 let ctx = RlsContext::tenant("tenant-positional");
2590 let query = Qail::add("_rls_positional_add_orders")
2591 .columns(["id", "total"])
2592 .values([Value::Int(1), Value::Int(100)])
2593 .with_rls(&ctx)
2594 .expect("rls should apply");
2595
2596 let sql = query.to_sql();
2597 assert!(
2598 sql.contains("tenant_id"),
2599 "tenant column should be injected"
2600 );
2601 assert!(
2602 sql.contains("VALUES (1, 100, 'tenant-positional')"),
2603 "insert payload should include injected tenant value in positional order: {sql}"
2604 );
2605 }
2606
2607 #[test]
2608 fn test_with_rls_add_positional_payload_overrides_existing_tenant_column_value() {
2609 seal_tenant_table("_rls_positional_add_override_orders", "tenant_id");
2610
2611 let ctx = RlsContext::tenant("tenant-final");
2612 let query = Qail::add("_rls_positional_add_override_orders")
2613 .columns(["id", "tenant_id", "total"])
2614 .values([
2615 Value::Int(1),
2616 Value::String("tenant-wrong".to_string()),
2617 Value::Int(50),
2618 ])
2619 .with_rls(&ctx)
2620 .expect("rls should apply");
2621
2622 let sql = query.to_sql();
2623 assert!(sql.contains("'tenant-final'"));
2624 assert!(!sql.contains("'tenant-wrong'"));
2625 }
2626
2627 #[test]
2628 fn test_with_rls_add_positional_payload_without_columns_errors() {
2629 seal_tenant_table("_rls_positional_add_without_columns_orders", "tenant_id");
2630
2631 let ctx = RlsContext::tenant("tenant-without-columns");
2632 let err = Qail::add("_rls_positional_add_without_columns_orders")
2633 .values([Value::Int(1), Value::Int(100)])
2634 .with_rls(&ctx)
2635 .expect_err("positional payload without columns should fail");
2636
2637 assert!(err.to_string().contains("requires explicit columns"));
2638 }
2639
2640 #[test]
2641 fn test_with_rls_replaces_qualified_tenant_filter() {
2642 seal_tenant_table("_rls_qualified_tenant_filter_orders", "tenant_id");
2643
2644 let ctx = RlsContext::tenant("tenant-final");
2645 let query = Qail::get("_rls_qualified_tenant_filter_orders")
2646 .filter("orders.tenant_id", Operator::Eq, "tenant-wrong")
2647 .with_rls(&ctx)
2648 .expect("rls should apply");
2649
2650 let sql = query.to_sql();
2651 assert!(sql.contains("'tenant-final'"));
2652 assert!(!sql.contains("'tenant-wrong'"));
2653 }
2654 #[test]
2657 fn tenant_injection_is_qualified_on_joined_get() {
2658 seal_tenant_table("_rls_q_articles", "tenant_id");
2659 seal_tenant_table("_rls_q_authors", "tenant_id");
2660 let ctx = RlsContext::tenant("t-1");
2661 let cmd = Qail::get("_rls_q_articles")
2662 .columns(["_rls_q_articles.id", "_rls_q_authors.name"])
2663 .inner_join(
2664 "_rls_q_authors",
2665 "_rls_q_articles.author_id",
2666 "_rls_q_authors.id",
2667 )
2668 .with_rls(&ctx)
2669 .expect("with_rls");
2670 let sql = cmd.to_sql();
2671 assert!(
2675 sql.contains("_rls_q_articles.tenant_id"),
2676 "primary tenant predicate must be table-qualified: {sql}"
2677 );
2678 }
2679
2680 #[test]
2681 fn tenant_injection_is_qualified_on_update_and_delete() {
2682 seal_tenant_table("_rls_q_upd", "tenant_id");
2683 let ctx = RlsContext::tenant("t-1");
2684 let upd = Qail::set("_rls_q_upd")
2685 .set_value("x", 1)
2686 .with_rls(&ctx)
2687 .expect("with_rls")
2688 .to_sql();
2689 assert!(
2690 upd.contains("_rls_q_upd.tenant_id"),
2691 "UPDATE tenant predicate must be table-qualified: {upd}"
2692 );
2693 let del = Qail::del("_rls_q_upd")
2694 .eq("id", "r-1")
2695 .with_rls(&ctx)
2696 .expect("with_rls")
2697 .to_sql();
2698 assert!(
2699 del.contains("_rls_q_upd.tenant_id"),
2700 "DELETE tenant predicate must be table-qualified: {del}"
2701 );
2702 }
2703
2704 #[test]
2705 fn global_scope_injection_is_qualified_on_joined_get() {
2706 seal_tenant_table("_rls_q_globals", "tenant_id");
2707 seal_tenant_table("_rls_q_globals_kin", "tenant_id");
2708 let ctx = RlsContext::global();
2709 let sql = Qail::get("_rls_q_globals")
2710 .inner_join(
2711 "_rls_q_globals_kin",
2712 "_rls_q_globals.kin_id",
2713 "_rls_q_globals_kin.id",
2714 )
2715 .with_rls(&ctx)
2716 .expect("with_rls")
2717 .to_sql();
2718 assert!(
2719 sql.contains("_rls_q_globals.tenant_id"),
2720 "global IS NULL predicate must be table-qualified: {sql}"
2721 );
2722 }
2723
2724 #[test]
2725 fn schema_qualified_injection_dedups_bare_and_relation_qualified_predicates() {
2726 for existing in ["tenant_id", "_rls_sq_orders.tenant_id"] {
2730 seal_tenant_table("public._rls_sq_orders", "tenant_id");
2731 let ctx = RlsContext::tenant("t-1");
2732 let cmd = Qail::get("public._rls_sq_orders")
2733 .eq(existing, "t-1")
2734 .with_rls(&ctx)
2735 .expect("with_rls");
2736 let scope_predicates = cmd
2737 .cages
2738 .iter()
2739 .filter(|c| matches!(c.kind, CageKind::Filter))
2740 .flat_map(|c| c.conditions.iter())
2741 .filter(|cond| matches!(&cond.left, Expr::Named(n) if n.ends_with("tenant_id")))
2742 .count();
2743 assert_eq!(
2744 scope_predicates,
2745 1,
2746 "exactly one scope predicate must remain for existing={existing}: {}",
2747 cmd.to_sql()
2748 );
2749 }
2750 }
2751
2752 #[test]
2753 fn identical_set_value_after_injection_collapses_to_one_entry() {
2754 seal_tenant_table("_rls_sv_ledger", "tenant_id");
2758 let ctx = RlsContext::tenant("t-ctx");
2759 let cmd = Qail::add("_rls_sv_ledger")
2760 .with_rls(&ctx)
2761 .expect("with_rls")
2762 .set_value("tenant_id", "t-ctx")
2763 .set_value("metric", "core_transactions");
2764 let payload_tenants = cmd
2765 .cages
2766 .iter()
2767 .filter(|c| matches!(c.kind, CageKind::Payload))
2768 .flat_map(|c| c.conditions.iter())
2769 .filter(|cond| matches!(&cond.left, Expr::Named(n) if n == "tenant_id"))
2770 .count();
2771 assert_eq!(payload_tenants, 1, "{}", cmd.to_sql());
2772 assert!(cmd.to_sql().contains("'t-ctx'"));
2773 }
2774
2775 #[test]
2776 fn schema_qualified_cross_join_keeps_the_joined_scope_predicate() {
2777 seal_tenant_table("public._rls_sqj_a", "tenant_id");
2783 seal_tenant_table("public._rls_sqj_b", "tenant_id");
2784 let ctx = RlsContext::tenant("t-1");
2785 let mut q = Qail::get("public._rls_sqj_a");
2786 q.joins.push(crate::ast::Join {
2787 table: "public._rls_sqj_b b".to_string(),
2788 kind: JoinKind::Cross,
2789 on: None,
2790 on_true: true,
2791 });
2792 let sql = q.with_rls(&ctx).expect("with_rls").to_sql();
2793 assert!(
2794 sql.contains("_rls_sqj_a.tenant_id = 't-1'"),
2795 "primary predicate must survive: {sql}"
2796 );
2797 assert!(
2798 sql.contains("b.tenant_id = 't-1'"),
2799 "cross-joined predicate must survive primary injection de-dup: {sql}"
2800 );
2801 }
2802
2803 #[test]
2804 fn schema_qualified_cross_join_keeps_the_joined_scope_predicate_under_global() {
2805 seal_tenant_table("public._rls_sqjg_a", "tenant_id");
2806 seal_tenant_table("public._rls_sqjg_b", "tenant_id");
2807 let mut q = Qail::get("public._rls_sqjg_a");
2808 q.joins.push(crate::ast::Join {
2809 table: "public._rls_sqjg_b b".to_string(),
2810 kind: JoinKind::Cross,
2811 on: None,
2812 on_true: true,
2813 });
2814 let sql = q
2815 .with_rls(&RlsContext::global())
2816 .expect("with_rls")
2817 .to_sql();
2818 assert!(
2819 sql.contains("_rls_sqjg_a.tenant_id IS NULL"),
2820 "primary IS NULL predicate must survive: {sql}"
2821 );
2822 assert!(
2823 sql.contains("b.tenant_id IS NULL"),
2824 "cross-joined IS NULL predicate must survive: {sql}"
2825 );
2826 }
2827
2828 #[test]
2829 fn identical_stamp_collapses_in_both_call_orders() {
2830 seal_tenant_table("_rls_dup_orders_a", "tenant_id");
2831 let ctx = RlsContext::tenant("t-9");
2832 let first = Qail::add("_rls_dup_orders_a")
2834 .with_rls(&ctx)
2835 .expect("with_rls")
2836 .set_value("tenant_id", "t-9")
2837 .set_value("x", 1);
2838 let second = Qail::add("_rls_dup_orders_a")
2840 .set_value("tenant_id", "t-9")
2841 .set_value("x", 1)
2842 .with_rls(&ctx)
2843 .expect("with_rls");
2844 for (label, cmd) in [("rls-first", first), ("stamp-first", second)] {
2845 let n = cmd
2846 .cages
2847 .iter()
2848 .filter(|c| matches!(c.kind, CageKind::Payload))
2849 .flat_map(|c| c.conditions.iter())
2850 .filter(|cond| matches!(&cond.left, Expr::Named(n) if n == "tenant_id"))
2851 .count();
2852 assert_eq!(n, 1, "{label}: {}", cmd.to_sql());
2853 }
2854 }
2855
2856 #[test]
2857 fn conflicting_stamp_after_injection_is_preserved_for_the_encoder_error() {
2858 seal_tenant_table("_rls_dup_orders_b", "tenant_id");
2862 let ctx = RlsContext::tenant("t-real");
2863 let cmd = Qail::add("_rls_dup_orders_b")
2864 .with_rls(&ctx)
2865 .expect("with_rls")
2866 .set_value("tenant_id", "t-spoof");
2867 let n = cmd
2868 .cages
2869 .iter()
2870 .filter(|c| matches!(c.kind, CageKind::Payload))
2871 .flat_map(|c| c.conditions.iter())
2872 .filter(|cond| matches!(&cond.left, Expr::Named(n) if n == "tenant_id"))
2873 .count();
2874 assert_eq!(n, 2, "conflicting duplicate must be preserved");
2875 }
2876
2877 #[test]
2878 fn owner_scope_stamp_follows_the_same_idempotence_rules() {
2879 seal_owner_table("_rls_dup_owner", "user_id");
2880 let ctx = RlsContext::user("u-1");
2881 let same = Qail::add("_rls_dup_owner")
2882 .with_rls(&ctx)
2883 .expect("with_rls")
2884 .set_value("user_id", "u-1");
2885 let count = |cmd: &Qail| {
2886 cmd.cages
2887 .iter()
2888 .filter(|c| matches!(c.kind, CageKind::Payload))
2889 .flat_map(|c| c.conditions.iter())
2890 .filter(|cond| matches!(&cond.left, Expr::Named(n) if n == "user_id"))
2891 .count()
2892 };
2893 assert_eq!(count(&same), 1);
2894 let conflicting = Qail::add("_rls_dup_owner")
2895 .with_rls(&ctx)
2896 .expect("with_rls")
2897 .set_value("user_id", "u-2");
2898 assert_eq!(count(&conflicting), 2);
2899 }
2900
2901 #[test]
2902 fn conflicting_set_coalesce_after_injection_remains_fail_closed() {
2903 seal_tenant_table("_rls_dup_coalesce", "tenant_id");
2907 let ctx = RlsContext::tenant("t-c");
2908 let cmd = Qail::add("_rls_dup_coalesce")
2909 .with_rls(&ctx)
2910 .expect("with_rls")
2911 .set_coalesce("tenant_id", "t-c");
2912 let n = cmd
2913 .cages
2914 .iter()
2915 .filter(|c| matches!(c.kind, CageKind::Payload))
2916 .flat_map(|c| c.conditions.iter())
2917 .filter(|cond| matches!(&cond.left, Expr::Named(n) if n == "tenant_id"))
2918 .count();
2919 assert_eq!(
2920 n,
2921 2,
2922 "conflicting set_coalesce must be preserved: {}",
2923 cmd.to_sql()
2924 );
2925 }
2926}