1use 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
31pub enum AuthorizedSearch {}
36
37impl AuthorizedSearch {
38 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 #[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 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 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 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 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
167fn 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
181fn 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 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
226fn 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
249fn 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 let _ = Role::Read;
339 }
340
341 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 #![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 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 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 #[test]
471 fn tenant_a_cannot_see_tenant_b_collection() {
472 let rt = rt();
473 let frame_a = FakeReadFrame::with_visible(set(&["a_orders"]));
475 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 #[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 #[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 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 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 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}