Skip to main content

reddb_server/runtime/
authorized_search.rs

1//! Authorized search entry point — issue #119.
2//!
3//! Wraps every SEARCH SIMILAR / SEARCH TEXT / SEARCH CONTEXT runtime
4//! call so that the candidate set is *pre-filtered* by the calling
5//! identity's `EffectiveScope.visible_collections` BEFORE any
6//! similarity score is computed. Without this gate AI commands could
7//! return rows outside the calling user's RLS scope and leak them via
8//! the LLM context window.
9//!
10//! The contract is intentionally narrow: every public function takes
11//! `scope: &dyn ReadFrame`, refuses with a structured `RedDBError` if
12//! the frame carries `None` for `visible_collections()`, and trims the
13//! candidate set to the intersection of the user-supplied collection
14//! list (if any) and the scope's allow-list. The legacy direct entry
15//! points (`RedDBRuntime::search_similar`, `search_text`,
16//! `search_context`) remain in place for tests and for callers that
17//! have already opened a frame; this module is the *canonical* entry
18//! that the `SEARCH SIMILAR / SEARCH CONTEXT` SQL commands and ASK go
19//! through.
20
21use std::collections::HashSet;
22
23use tracing::{debug, info_span, warn};
24
25use super::statement_frame::ReadFrame;
26use super::RedDBRuntime;
27use crate::api::{RedDBError, RedDBResult};
28use crate::application::SearchContextInput;
29use crate::storage::unified::devx::SimilarResult;
30
31/// Surface area used by the AI runtime entry points. Holds a reference
32/// to the scope and the canonical functions; kept as an empty enum so
33/// callers spell `AuthorizedSearch::execute_*(...)` (matching the
34/// shape required by issue #119).
35pub enum AuthorizedSearch {}
36
37impl AuthorizedSearch {
38    /// Authorized SEARCH SIMILAR. Refuses with a structured error when
39    /// the caller's scope has no `visible_collections` set, or when the
40    /// requested collection is outside that set. Otherwise dispatches
41    /// to the underlying `RedDBRuntime::search_similar`.
42    pub(crate) fn execute_similar(
43        runtime: &RedDBRuntime,
44        scope: &dyn ReadFrame,
45        collection: &str,
46        vector: &[f32],
47        k: usize,
48        min_score: f32,
49    ) -> RedDBResult<Vec<SimilarResult>> {
50        let span = info_span!(
51            "authorized_search.similar",
52            collection = collection,
53            tenant = ?scope.effective_scope(),
54        );
55        let _enter = span.enter();
56
57        let visible = require_visible(scope, "SEARCH SIMILAR")?;
58        if !visible.contains(collection) {
59            warn!(
60                target: "authorized_search",
61                collection = collection,
62                "denied: collection outside visible scope"
63            );
64            return Err(RedDBError::Query(format!(
65                "permission denied: collection `{collection}` is not in the caller's visible scope"
66            )));
67        }
68        debug!(target: "authorized_search", "scope-checked, dispatching");
69        with_read_frame(runtime, scope, || {
70            runtime.search_similar(collection, vector, k, min_score)
71        })
72    }
73
74    /// Authorized SEARCH TEXT. The underlying executor accepts an
75    /// optional collection list; we intersect it with the visible set
76    /// before forwarding so collections outside scope never enter the
77    /// candidate pool.
78    #[allow(clippy::too_many_arguments)]
79    pub(crate) fn execute_text(
80        runtime: &RedDBRuntime,
81        scope: &dyn ReadFrame,
82        query: String,
83        collections: Option<Vec<String>>,
84        entity_types: Option<Vec<String>>,
85        capabilities: Option<Vec<String>>,
86        fields: Option<Vec<String>>,
87        limit: Option<usize>,
88        fuzzy: bool,
89    ) -> RedDBResult<crate::storage::unified::dsl::QueryResult> {
90        let span = info_span!(
91            "authorized_search.text",
92            tenant = ?scope.effective_scope(),
93        );
94        let _enter = span.enter();
95
96        let visible = require_visible(scope, "SEARCH TEXT")?;
97        let constrained = constrain_collections(collections, visible);
98        if let Some(ref c) = constrained {
99            if c.is_empty() {
100                // Caller asked for collections outside their scope —
101                // refuse loudly rather than fall through to the global
102                // path (which would scan every collection ignoring the
103                // intent).
104                return Err(RedDBError::Query(
105                    "permission denied: no requested collection is in the caller's visible scope"
106                        .to_string(),
107                ));
108            }
109        }
110        with_read_frame(runtime, scope, || {
111            runtime.search_text(
112                query,
113                constrained,
114                entity_types,
115                capabilities,
116                fields,
117                limit,
118                fuzzy,
119            )
120        })
121    }
122
123    /// Authorized SEARCH CONTEXT. Pre-filters the input collection list
124    /// against the visible scope and post-filters every result bucket
125    /// so cross-ref / graph / vector expansion can't leak rows from
126    /// outside the scope. Refuses with a structured error when the
127    /// scope has no visible-collections set.
128    pub(crate) fn execute_context(
129        runtime: &RedDBRuntime,
130        scope: &dyn ReadFrame,
131        mut input: SearchContextInput,
132    ) -> RedDBResult<crate::runtime::ContextSearchResult> {
133        let span = info_span!(
134            "authorized_search.context",
135            tenant = ?scope.effective_scope(),
136        );
137        let _enter = span.enter();
138
139        let visible = require_visible(scope, "SEARCH CONTEXT")?;
140
141        // Pre-filter the request: drop any caller-supplied collection
142        // that's outside the visible set, and force the global-scan
143        // path to also stay inside the visible set by passing the
144        // intersection through.
145        input.collections = constrain_collections(input.collections, visible);
146        if let Some(ref c) = input.collections {
147            if c.is_empty() {
148                return Err(RedDBError::Query(
149                    "permission denied: no requested collection is in the caller's visible scope"
150                        .to_string(),
151                ));
152            }
153        } else {
154            // No caller list — substitute the visible set so the
155            // global-scan tier stays bounded by it.
156            let mut bounded: Vec<String> = visible.iter().cloned().collect();
157            bounded.sort();
158            input.collections = Some(bounded);
159        }
160
161        let mut result = with_read_frame(runtime, scope, || runtime.search_context(input))?;
162        post_filter_context_result(&mut result, visible);
163        Ok(result)
164    }
165}
166
167/// Bridge legacy search executors to the frame without re-resolving identity,
168/// tenant or transaction state. The existing guard restores all three on unwind.
169fn with_read_frame<R>(runtime: &RedDBRuntime, frame: &dyn ReadFrame, run: impl FnOnce() -> R) -> R {
170    let bundle = super::execution_context::SnapshotBundle {
171        snapshot: Some(frame.snapshot_context(runtime)),
172        auth: frame
173            .identity()
174            .map(|(name, role)| (name.to_string(), role)),
175        auth_principal: None,
176        tenant: frame.effective_scope().map(str::to_string),
177    };
178    super::execution_context::with_snapshot_bundle(&bundle, run)
179}
180
181/// Defence-in-depth pass run after `search_context` returns. The
182/// `input.collections` pre-filter already bounds the corpus, but the
183/// cross-ref / graph / vector expansion paths resolve
184/// `xref.target_collection` and `entity.kind.collection()`
185/// independently. Re-filtering each bucket here ensures a regression
186/// in one of those paths can't leak rows from outside `allowed`.
187///
188/// Factored out as a free function so the property test (256 cases)
189/// can drive the invariant without booting a runtime.
190fn post_filter_context_result(
191    result: &mut crate::runtime::ContextSearchResult,
192    allowed: &HashSet<String>,
193) {
194    let retain = |bucket: &mut Vec<crate::runtime::ContextEntity>| {
195        bucket.retain(|e| allowed.contains(&e.collection));
196    };
197    retain(&mut result.tables);
198    retain(&mut result.graph.nodes);
199    retain(&mut result.graph.edges);
200    retain(&mut result.vectors);
201    retain(&mut result.documents);
202    retain(&mut result.key_values);
203
204    // Connections reference entity ids; recompute visible ids so a
205    // dangling edge into a filtered-out row is dropped.
206    let visible_ids: HashSet<u64> = std::iter::empty()
207        .chain(result.tables.iter().map(|e| e.entity.id.raw()))
208        .chain(result.graph.nodes.iter().map(|e| e.entity.id.raw()))
209        .chain(result.graph.edges.iter().map(|e| e.entity.id.raw()))
210        .chain(result.vectors.iter().map(|e| e.entity.id.raw()))
211        .chain(result.documents.iter().map(|e| e.entity.id.raw()))
212        .chain(result.key_values.iter().map(|e| e.entity.id.raw()))
213        .collect();
214    result
215        .connections
216        .retain(|c| visible_ids.contains(&c.from_id) && visible_ids.contains(&c.to_id));
217
218    result.summary.total_entities = result.tables.len()
219        + result.graph.nodes.len()
220        + result.graph.edges.len()
221        + result.vectors.len()
222        + result.documents.len()
223        + result.key_values.len();
224}
225
226/// Resolve the visible-collections set on a frame, refusing with a
227/// structured error when none is wired. Centralised so every entry
228/// point produces the same error string and tracing event.
229fn require_visible<'a>(
230    scope: &'a dyn ReadFrame,
231    op: &'static str,
232) -> RedDBResult<&'a HashSet<String>> {
233    match scope.visible_collections() {
234        Some(set) => Ok(set),
235        None => {
236            warn!(
237                target: "authorized_search",
238                op = op,
239                "refused: no visible-collections scope on frame"
240            );
241            Err(RedDBError::Query(format!(
242                "{op} requires an authenticated scope with visible_collections; \
243                 none was attached to the runtime frame"
244            )))
245        }
246    }
247}
248
249/// Restrict both explicit and global requests to the frame's allow-list.
250fn constrain_collections(
251    requested: Option<Vec<String>>,
252    visible: &HashSet<String>,
253) -> Option<Vec<String>> {
254    match requested {
255        None => {
256            let mut bounded: Vec<_> = visible.iter().cloned().collect();
257            bounded.sort();
258            Some(bounded)
259        }
260        Some(list) => {
261            let filtered: Vec<String> = list.into_iter().filter(|c| visible.contains(c)).collect();
262            Some(filtered)
263        }
264    }
265}
266
267#[cfg(test)]
268mod tests {
269    use super::*;
270    use crate::api::RedDBOptions;
271    use crate::auth::Role;
272    use crate::runtime::statement_frame::test_support::FakeReadFrame;
273    use crate::runtime::RedDBRuntime;
274
275    fn rt() -> RedDBRuntime {
276        RedDBRuntime::with_options(RedDBOptions::in_memory()).expect("rt")
277    }
278
279    fn set(items: &[&str]) -> HashSet<String> {
280        items.iter().map(|s| s.to_string()).collect()
281    }
282
283    #[test]
284    fn execute_similar_refuses_without_scope() {
285        let rt = rt();
286        let frame = FakeReadFrame::without_scope();
287        let err = AuthorizedSearch::execute_similar(&rt, &frame, "orders", &[0.1], 1, 0.0)
288            .expect_err("refuses without scope");
289        assert!(format!("{err}").contains("requires an authenticated scope"));
290    }
291
292    #[test]
293    fn execute_similar_refuses_collection_outside_scope() {
294        let rt = rt();
295        let frame = FakeReadFrame::with_visible(set(&["orders"]));
296        let err = AuthorizedSearch::execute_similar(&rt, &frame, "secrets", &[0.1], 1, 0.0)
297            .expect_err("refuses out-of-scope collection");
298        assert!(format!("{err}").contains("not in the caller's visible scope"));
299    }
300
301    #[test]
302    fn execute_context_refuses_without_scope() {
303        let rt = rt();
304        let frame = FakeReadFrame::without_scope();
305        let err = AuthorizedSearch::execute_context(
306            &rt,
307            &frame,
308            SearchContextInput {
309                query: "x".into(),
310                field: None,
311                vector: None,
312                collections: None,
313                graph_depth: None,
314                graph_max_edges: None,
315                max_cross_refs: None,
316                follow_cross_refs: None,
317                expand_graph: None,
318                global_scan: None,
319                reindex: None,
320                limit: None,
321                min_score: None,
322            },
323        )
324        .expect_err("refuses without scope");
325        assert!(format!("{err}").contains("requires an authenticated scope"));
326    }
327
328    #[test]
329    fn constrain_collections_drops_out_of_scope_items() {
330        let visible = set(&["a", "b"]);
331        let got = constrain_collections(Some(vec!["a".into(), "c".into()]), &visible);
332        assert_eq!(got, Some(vec!["a".into()]));
333        assert_eq!(
334            constrain_collections(None, &visible),
335            Some(vec!["a".into(), "b".into()])
336        );
337        // Touch `Role` so the import isn't dropped if test fixtures grow.
338        let _ = Role::Read;
339    }
340
341    // -----------------------------------------------------------------
342    // Property test (issue #119): every result row's collection ∈
343    // scope.visible_collections after `post_filter_context_result`.
344    // 256 cases as the issue requires.
345    // -----------------------------------------------------------------
346
347    use crate::runtime::{
348        ContextConnection, ContextConnectionType, ContextEntity, ContextGraphResult,
349        ContextSearchResult, ContextSummary, DiscoveryMethod,
350    };
351    use crate::storage::unified::entity::{EntityData, EntityKind, RowData, UnifiedEntity};
352    use crate::storage::unified::EntityId;
353    use proptest::prelude::*;
354
355    fn fake_entity(id: u64, collection: &str) -> UnifiedEntity {
356        UnifiedEntity::new(
357            EntityId::new(id),
358            EntityKind::TableRow {
359                table: std::sync::Arc::from(collection),
360                row_id: id,
361            },
362            EntityData::Row(RowData::new(Vec::new())),
363        )
364    }
365
366    fn fake_ctx_entity(id: u64, collection: &str) -> ContextEntity {
367        ContextEntity {
368            entity: fake_entity(id, collection),
369            score: 0.5,
370            discovery: DiscoveryMethod::GlobalScan,
371            collection: collection.to_string(),
372        }
373    }
374
375    fn empty_summary() -> ContextSummary {
376        ContextSummary {
377            total_entities: 0,
378            direct_matches: 0,
379            expanded_via_graph: 0,
380            expanded_via_cross_refs: 0,
381            expanded_via_vector_query: 0,
382            collections_searched: 0,
383            execution_time_us: 0,
384            tiers_used: Vec::new(),
385            entities_reindexed: 0,
386        }
387    }
388
389    fn build_result(rows: &[(u64, &str)]) -> ContextSearchResult {
390        let entities: Vec<ContextEntity> =
391            rows.iter().map(|(id, c)| fake_ctx_entity(*id, c)).collect();
392        ContextSearchResult {
393            query: "x".into(),
394            tables: entities.clone(),
395            graph: ContextGraphResult {
396                nodes: entities.clone(),
397                edges: Vec::new(),
398            },
399            vectors: entities.clone(),
400            documents: Vec::new(),
401            key_values: Vec::new(),
402            connections: vec![ContextConnection {
403                from_id: rows.first().map(|(id, _)| *id).unwrap_or(0),
404                to_id: rows.last().map(|(id, _)| *id).unwrap_or(0),
405                connection_type: ContextConnectionType::CrossRef("x".into()),
406                weight: 1.0,
407            }],
408            summary: empty_summary(),
409        }
410    }
411
412    proptest! {
413        // Issue #119: every result row's collection MUST be in
414        // `visible_collections` after AuthorizedSearch's defence-in-
415        // depth post-filter. Run 256 cases with arbitrary mixes of
416        // collection names and visible sets.
417        #![proptest_config(ProptestConfig::with_cases(256))]
418        #[test]
419        fn every_result_row_is_in_visible_set(
420            row_collections in proptest::collection::vec("[a-z]{1,4}", 0..10),
421            visible in proptest::collection::hash_set("[a-z]{1,4}", 0..6),
422        ) {
423            let rows: Vec<(u64, &str)> = row_collections
424                .iter()
425                .enumerate()
426                .map(|(i, c)| (i as u64 + 1, c.as_str()))
427                .collect();
428            let mut result = build_result(&rows);
429            post_filter_context_result(&mut result, &visible);
430
431            // The invariant: nothing escapes the visible scope.
432            for e in result.tables.iter()
433                .chain(result.graph.nodes.iter())
434                .chain(result.graph.edges.iter())
435                .chain(result.vectors.iter())
436                .chain(result.documents.iter())
437                .chain(result.key_values.iter())
438            {
439                prop_assert!(visible.contains(&e.collection),
440                    "leaked row collection={} not in visible={:?}",
441                    e.collection, visible);
442            }
443            // Connections only reference visible-id pairs.
444            let visible_ids: HashSet<u64> = std::iter::empty()
445                .chain(result.tables.iter().map(|e| e.entity.id.raw()))
446                .chain(result.graph.nodes.iter().map(|e| e.entity.id.raw()))
447                .chain(result.graph.edges.iter().map(|e| e.entity.id.raw()))
448                .chain(result.vectors.iter().map(|e| e.entity.id.raw()))
449                .chain(result.documents.iter().map(|e| e.entity.id.raw()))
450                .chain(result.key_values.iter().map(|e| e.entity.id.raw()))
451                .collect();
452            for c in &result.connections {
453                prop_assert!(visible_ids.contains(&c.from_id) && visible_ids.contains(&c.to_id),
454                    "dangling connection {} -> {} survived filter",
455                    c.from_id, c.to_id);
456            }
457        }
458    }
459
460    // -----------------------------------------------------------------
461    // Regression test (issue #119): tenant A user runs SEARCH SIMILAR;
462    // tenant B rows never enter the result.
463    //
464    // Drives the boundary check directly through `AuthorizedSearch::
465    // execute_similar` with an `EffectiveScope` whose visible set
466    // mirrors what `AuthStore::visible_collections_for_scope` returns
467    // for tenant A. Asking for a tenant-B collection must refuse.
468    // -----------------------------------------------------------------
469
470    #[test]
471    fn tenant_a_cannot_see_tenant_b_collection() {
472        let rt = rt();
473        // Tenant A's caller — visible set restricted to A's collection.
474        let frame_a = FakeReadFrame::with_visible(set(&["a_orders"]));
475        // SEARCH SIMILAR against `b_orders` (tenant B's collection)
476        // must refuse with the structured permission-denied error,
477        // BEFORE any similarity score is computed (the underlying
478        // `search_similar` call is never reached because
479        // `visible.contains` short-circuits first).
480        let err = AuthorizedSearch::execute_similar(&rt, &frame_a, "b_orders", &[0.1], 1, 0.0)
481            .expect_err("tenant-A scope must refuse tenant-B collection");
482        assert!(format!("{err}").contains("not in the caller's visible scope"));
483    }
484
485    /// Companion regression: SEARCH CONTEXT also rejects when every
486    /// requested collection is outside the caller's visible scope.
487    /// Without the pre-filter, the global-scan tier would scan every
488    /// collection in the DB (including tenant B's) and only the
489    /// per-row RLS gate would catch leaks — which is exactly the
490    /// failure mode #119 closes.
491    #[test]
492    fn search_context_refuses_all_out_of_scope_collections() {
493        let rt = rt();
494        let frame = FakeReadFrame::with_visible(set(&["a_orders"]));
495        let err = AuthorizedSearch::execute_context(
496            &rt,
497            &frame,
498            SearchContextInput {
499                query: "x".into(),
500                field: None,
501                vector: None,
502                collections: Some(vec!["b_orders".into(), "b_customers".into()]),
503                graph_depth: None,
504                graph_max_edges: None,
505                max_cross_refs: None,
506                follow_cross_refs: None,
507                expand_graph: None,
508                global_scan: None,
509                reindex: None,
510                limit: None,
511                min_score: None,
512            },
513        )
514        .expect_err("all-out-of-scope SEARCH CONTEXT must refuse");
515        let msg = format!("{err}");
516        assert!(
517            msg.contains("no requested collection is in the caller's visible scope"),
518            "expected scope-refusal, got: {msg}"
519        );
520    }
521
522    // -----------------------------------------------------------------
523    // Cache hit-rate metric is exposed (issue #119 acceptance).
524    // -----------------------------------------------------------------
525
526    #[test]
527    fn auth_cache_stats_are_exposed_via_authstore() {
528        use crate::auth::store::AuthStore;
529        use crate::auth::AuthConfig;
530        let store = AuthStore::new(AuthConfig::default());
531        let stats0 = store.auth_cache_stats();
532        assert_eq!(stats0.hits + stats0.misses, 0);
533        // Drive a miss + insert via the public API.
534        let _ = store.visible_collections_for_scope(
535            None,
536            Role::Read,
537            "alice",
538            &vec!["orders".to_string()],
539        );
540        let stats1 = store.auth_cache_stats();
541        assert!(
542            stats1.misses >= 1,
543            "first lookup must record a miss, got {stats1:?}"
544        );
545        // Second call hits the freshly-populated entry.
546        let _ = store.visible_collections_for_scope(
547            None,
548            Role::Read,
549            "alice",
550            &vec!["orders".to_string()],
551        );
552        let stats2 = store.auth_cache_stats();
553        assert!(
554            stats2.hits >= 1,
555            "second lookup must record a hit, got {stats2:?}"
556        );
557        // Hit rate is computable.
558        let _ = stats2.hit_rate();
559    }
560
561    #[test]
562    fn visible_collections_cache_keeps_principals_separate() {
563        use crate::auth::privileges::{Action, GrantPrincipal, Resource};
564        use crate::auth::store::AuthStore;
565        use crate::auth::{AuthConfig, UserId};
566
567        let store = AuthStore::new(AuthConfig::default());
568        store.create_user("admin", "p", Role::Admin).unwrap();
569        store.create_user("alice", "p", Role::Read).unwrap();
570        store.create_user("bob", "p", Role::Read).unwrap();
571        store
572            .grant(
573                &UserId::platform("admin"),
574                Role::Admin,
575                GrantPrincipal::User(UserId::platform("alice")),
576                Resource::table_from_name("orders"),
577                vec![Action::Select],
578                false,
579                None,
580            )
581            .expect("grant alice orders");
582
583        let collections = vec!["orders".to_string()];
584        let alice = store.visible_collections_for_scope(None, Role::Read, "alice", &collections);
585        let bob = store.visible_collections_for_scope(None, Role::Read, "bob", &collections);
586
587        assert!(alice.contains("orders"));
588        assert!(
589            !bob.contains("orders"),
590            "bob must not reuse alice's visible-collections cache entry"
591        );
592    }
593}
594
595#[cfg(test)]
596mod frame_regression_tests {
597    use super::*;
598    use crate::api::RedDBOptions;
599    use crate::auth::Role;
600    use crate::runtime::execution_context::{
601        snapshot_bundle, with_snapshot_bundle, SnapshotBundle,
602    };
603    use crate::runtime::statement_frame::test_support::FakeReadFrame;
604    use crate::storage::unified::entity::EntityData;
605    use reddb_types::Value;
606
607    fn text(runtime: &RedDBRuntime, frame: &dyn ReadFrame, limit: usize) -> Vec<String> {
608        let result = AuthorizedSearch::execute_text(
609            runtime,
610            frame,
611            "needle".into(),
612            None,
613            None,
614            None,
615            None,
616            Some(limit),
617            false,
618        )
619        .expect("text search");
620        result
621            .matches
622            .into_iter()
623            .map(|hit| {
624                let EntityData::Row(row) = hit.entity.data else {
625                    panic!("row")
626                };
627                let Some(Value::Text(value)) = row.get_field("body") else {
628                    panic!("text")
629                };
630                value.to_string()
631            })
632            .collect()
633    }
634
635    #[test]
636    fn search_text_uses_frame_allowlist_and_identity_before_limit() {
637        let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).expect("runtime");
638        for sql in [
639            "CREATE TABLE docs (owner TEXT, body TEXT)",
640            "CREATE TABLE secrets (owner TEXT, body TEXT)",
641            "INSERT INTO docs (owner,body) VALUES ('bob','needle denied'),('alice','needle visible')",
642            "INSERT INTO secrets (owner,body) VALUES ('alice','needle secret')",
643            "CREATE POLICY own ON docs FOR SELECT TO read USING (owner=CURRENT_USER())",
644            "ALTER TABLE docs ENABLE ROW LEVEL SECURITY",
645        ] { rt.execute_query(sql).expect(sql); }
646        let mut frame = FakeReadFrame::with_visible(["docs".into()].into_iter().collect());
647        frame.snapshot = rt.current_snapshot();
648        let outer = SnapshotBundle {
649            auth: Some(("bob".into(), Role::Admin)),
650            auth_principal: Some(crate::auth::UserId::platform("bob")),
651            tenant: Some("other".into()),
652            snapshot: None,
653        };
654        with_snapshot_bundle(&outer, || {
655            assert_eq!(text(&rt, &frame, 1), ["needle visible"]);
656            assert_eq!(text(&rt, &frame, 10), ["needle visible"]);
657            let restored = snapshot_bundle();
658            assert_eq!(restored.auth, outer.auth);
659            assert_eq!(restored.tenant, outer.tenant);
660            assert!(restored.snapshot.is_none());
661        });
662        frame.visible = Some(HashSet::new());
663        assert!(AuthorizedSearch::execute_text(
664            &rt,
665            &frame,
666            "needle".into(),
667            None,
668            None,
669            None,
670            None,
671            Some(10),
672            false
673        )
674        .is_err());
675    }
676
677    #[test]
678    fn search_text_reads_frame_snapshot_even_after_committed_update() {
679        let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).expect("runtime");
680        rt.execute_query("CREATE TABLE docs (id INT PRIMARY KEY, body TEXT)")
681            .expect("table");
682        rt.execute_query("INSERT INTO docs (id,body) VALUES (1,'needle old')")
683            .expect("row");
684        let mut frame = FakeReadFrame::with_visible(["docs".into()].into_iter().collect());
685        frame.snapshot = rt.current_snapshot();
686        rt.execute_query("UPDATE docs SET body='needle new' WHERE id=1")
687            .expect("update");
688        assert_eq!(text(&rt, &frame, 10), ["needle old"]);
689        frame.snapshot = rt.current_snapshot();
690        assert_eq!(text(&rt, &frame, 10), ["needle new"]);
691    }
692
693    #[test]
694    fn search_similar_filters_policy_before_topk_and_uses_frame_identity() {
695        let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).expect("runtime");
696        for sql in [
697            "CREATE VECTOR embeddings DIM 2 METRIC cosine",
698            "INSERT INTO embeddings VECTOR (dense,content) VALUES ([1.0,0.0],'denied') WITH METADATA (owner='bob')",
699            "INSERT INTO embeddings VECTOR (dense,content) VALUES ([0.8,0.6],'visible') WITH METADATA (owner='alice')",
700            "CREATE POLICY own ON VECTORS OF embeddings FOR SELECT TO read USING (metadata.owner=CURRENT_USER())",
701            "ALTER TABLE embeddings ENABLE ROW LEVEL SECURITY",
702        ] { rt.execute_query(sql).expect(sql); }
703        let mut frame = FakeReadFrame::with_visible(["embeddings".into()].into_iter().collect());
704        frame.snapshot = rt.current_snapshot();
705        with_snapshot_bundle(
706            &SnapshotBundle {
707                auth: Some(("bob".into(), Role::Admin)),
708                ..Default::default()
709            },
710            || {
711                let hits = AuthorizedSearch::execute_similar(
712                    &rt,
713                    &frame,
714                    "embeddings",
715                    &[1.0, 0.0],
716                    1,
717                    0.0,
718                )
719                .expect("similar");
720                assert_eq!(hits.len(), 1);
721                let EntityData::Vector(data) = &hits[0].entity.data else {
722                    panic!("vector")
723                };
724                assert_eq!(data.content.as_deref(), Some("visible"));
725            },
726        );
727    }
728    #[test]
729    fn search_scope_preserves_own_savepoint_writes_and_aborts() {
730        use crate::runtime::statement_frame::{StatementExecutionFrame, StatementIdentity};
731        let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).expect("runtime");
732        for sql in [
733            "CREATE TABLE docs (id INT, body TEXT)",
734            "BEGIN",
735            "INSERT INTO docs (id,body) VALUES (1,'needle parent')",
736            "SAVEPOINT child",
737            "INSERT INTO docs (id,body) VALUES (2,'needle child')",
738        ] {
739            rt.execute_query(sql).expect(sql);
740        }
741        let scope = {
742            let frame = StatementExecutionFrame::build(
743                &rt,
744                StatementIdentity::Text("SEARCH TEXT 'needle' IN docs"),
745            )
746            .expect("frame");
747            let _installed = frame.install(&rt);
748            let mut scope = rt.ai_scope();
749            scope.visible_collections = Some(["docs".into()].into_iter().collect());
750            scope
751        };
752        let mut rows = text(&rt, &scope, 10);
753        rows.sort();
754        assert_eq!(rows, ["needle child", "needle parent"]);
755        rt.execute_query("ROLLBACK TO SAVEPOINT child")
756            .expect("rollback child");
757        assert_eq!(text(&rt, &scope, 10), ["needle parent"]);
758        rt.execute_query("ROLLBACK").expect("rollback parent");
759        assert!(text(&rt, &scope, 10).is_empty());
760    }
761}