Skip to main content

velesdb_core/database/
gated_search.rs

1//! Governed (gated) read primitives at the [`Database`] facade.
2//!
3//! Search entry points that do not build a `VelesQL` `Query` — REST vector /
4//! text / hybrid search, and memory recall — historically bypassed the
5//! control-plane read gate because [`Database::get_vector_collection`] hands
6//! back a detached [`VectorCollection`](crate::VectorCollection) with no
7//! observer reference. These methods restore governance for those paths: they
8//! consult the observer via [`Database::read_gate_raw`] (the same resolver the
9//! `VelesQL` gate uses), then delegate to the collection search leaf, applying any
10//! observer-supplied scope narrowing.
11//!
12//! When no observer is registered the gate is a single `Option` check and the
13//! search runs exactly as the ungated path did (zero-overhead contract).
14
15use crate::collection::VectorCollection;
16use crate::filter::{Condition, Filter};
17use crate::observer::QueryOperationKind;
18use crate::point::SearchResult;
19use crate::{Error, Result};
20
21use super::query_engine::RawGateOutcome;
22use super::Database;
23
24/// A non-VelesQL read routed through the control-plane gate.
25///
26/// Each variant maps to a [`VectorCollection`](crate::VectorCollection) search
27/// leaf and to a [`QueryOperationKind`] the observer sees. Observer-supplied
28/// scope filters are AND-composed with any caller filter before execution, so
29/// narrowing can only shrink the result set, never widen it.
30#[derive(Debug, Clone, Copy)]
31pub enum GatedRead<'a> {
32    /// Dense kNN similarity search.
33    ///
34    /// `ef` / `quality` tune recall via the engine's dedicated entry points.
35    /// They are honoured only when no filter is in effect: when a caller filter
36    /// or an observer scope filter is present the search routes through the
37    /// filtered leaf (`search_with_filter`), which the engine exposes
38    /// separately from the tuning leaves.
39    Dense {
40        /// Query vector.
41        query: &'a [f32],
42        /// Number of neighbours to return.
43        k: usize,
44        /// Optional `ef_search` override.
45        ef: Option<usize>,
46        /// Optional named search-quality profile.
47        quality: Option<crate::SearchQuality>,
48        /// Optional caller metadata filter.
49        filter: Option<&'a Filter>,
50    },
51    /// Full-text / BM25 search.
52    Text {
53        /// Query text.
54        query: &'a str,
55        /// Number of results to return.
56        k: usize,
57        /// Optional caller metadata filter.
58        filter: Option<&'a Filter>,
59    },
60    /// Hybrid dense + BM25 fused search.
61    Hybrid {
62        /// Dense query vector.
63        vector: &'a [f32],
64        /// Query text.
65        text: &'a str,
66        /// Number of results to return.
67        k: usize,
68        /// Optional dense/text blend factor.
69        alpha: Option<f32>,
70        /// Optional caller metadata filter.
71        filter: Option<&'a Filter>,
72    },
73}
74
75impl GatedRead<'_> {
76    /// The [`QueryOperationKind`] the observer is told this read represents, so
77    /// premium RBAC/audit records the correct operation label.
78    fn operation_kind(&self) -> QueryOperationKind {
79        match self {
80            GatedRead::Dense { .. } => QueryOperationKind::VectorSearch,
81            GatedRead::Text { .. } => QueryOperationKind::TextSearch,
82            GatedRead::Hybrid { .. } => QueryOperationKind::HybridSearch,
83        }
84    }
85}
86
87/// Lowers an [`AccessScope`](crate::observer::AccessScope) filter — expressed in
88/// the `VelesQL` [`Condition`](crate::velesql::Condition) language — into the
89/// lower-level [`filter::Filter`](crate::filter::Filter) the raw search leaves
90/// accept. Infallible: reuses the existing
91/// `From<velesql::Condition> for filter::Condition` conversion (the same
92/// lowering the WHERE evaluator uses).
93#[must_use]
94pub(crate) fn scope_to_core_filter(condition: crate::velesql::Condition) -> Filter {
95    Filter::new(Condition::from(condition))
96}
97
98/// AND-composes a caller filter with an observer scope filter. The result
99/// matches only rows satisfying both, so composing a scope can only narrow.
100fn and_filters(caller: Option<&Filter>, scope: Option<Filter>) -> Option<Filter> {
101    match (caller, scope) {
102        (None, None) => None,
103        (Some(c), None) => Some(c.clone()),
104        (None, Some(s)) => Some(s),
105        (Some(c), Some(s)) => Some(Filter::new(Condition::And {
106            conditions: vec![c.condition.clone(), s.condition],
107        })),
108    }
109}
110
111/// Runs a dense kNN search, picking the leaf by the effective filter and tuning.
112/// A filter (caller filter already AND-composed with any scope) routes through
113/// the filtered leaf; otherwise `ef` wins over `quality`, then plain search.
114fn run_dense(
115    coll: &VectorCollection,
116    query: &[f32],
117    k: usize,
118    ef: Option<usize>,
119    quality: Option<crate::SearchQuality>,
120    filter: Option<Filter>,
121) -> Result<Vec<SearchResult>> {
122    match filter {
123        Some(f) => coll.search_with_filter(query, k, &f),
124        None => match (ef, quality) {
125            (Some(ef), _) => coll.search_with_ef(query, k, ef),
126            (None, Some(q)) => coll.search_with_quality(query, k, q),
127            (None, None) => coll.search(query, k),
128        },
129    }
130}
131
132/// Dispatches a gated read to its collection search leaf, AND-composing the
133/// observer scope filter with any caller filter first.
134fn dispatch_gated_read(
135    coll: &VectorCollection,
136    read: GatedRead<'_>,
137    scope_filter: Option<Filter>,
138) -> Result<Vec<SearchResult>> {
139    match read {
140        GatedRead::Dense {
141            query,
142            k,
143            ef,
144            quality,
145            filter,
146        } => run_dense(
147            coll,
148            query,
149            k,
150            ef,
151            quality,
152            and_filters(filter, scope_filter),
153        ),
154        GatedRead::Text { query, k, filter } => match and_filters(filter, scope_filter) {
155            Some(f) => coll.text_search_with_filter(query, k, &f),
156            None => coll.text_search(query, k),
157        },
158        GatedRead::Hybrid {
159            vector,
160            text,
161            k,
162            alpha,
163            filter,
164        } => match and_filters(filter, scope_filter) {
165            Some(f) => coll.hybrid_search_with_filter(vector, text, k, alpha, &f),
166            None => coll.hybrid_search(vector, text, k, alpha),
167        },
168    }
169}
170
171impl Database {
172    /// Public read-gate check for search paths that do not map onto
173    /// [`GatedRead`] — sparse, batch, multi-query, graph-embedding and MATCH —
174    /// so their callers can enforce governance without a bespoke gated method
175    /// per return type. Consults the observer for the given collection /
176    /// operation / principal / tenant and reports what the caller must do:
177    ///
178    /// * `Ok(None)` — allow the read unmodified;
179    /// * `Ok(Some(filter))` — allow, but AND this scope filter into the search
180    ///   (callers whose search variant cannot apply a metadata filter must fail
181    ///   closed rather than run unfiltered);
182    /// * `Err(_)` — the read is denied (or the observer failed internally); the
183    ///   caller must not touch the data plane.
184    ///
185    /// With no observer registered this is a single `Option` check returning
186    /// `Ok(None)` (zero-overhead contract).
187    ///
188    /// # Errors
189    ///
190    /// Returns the observer's `Deny` error when access is refused, or the
191    /// observer's own error on an internal failure.
192    pub fn authorize_read(
193        &self,
194        collection: &str,
195        operation: QueryOperationKind,
196        principal: Option<&str>,
197        tenant_hint: Option<&str>,
198    ) -> Result<Option<Filter>> {
199        match self.read_gate_raw(collection, operation, principal, tenant_hint)? {
200            RawGateOutcome::Allow => Ok(None),
201            RawGateOutcome::Deny(err) => Err(err),
202            RawGateOutcome::Scope(scope) => Ok(scope.filter.map(scope_to_core_filter)),
203        }
204    }
205
206    /// Executes a search through the control-plane read gate.
207    ///
208    /// Consults the registered observer via
209    /// [`read_gate_raw`](Self::read_gate_raw) for the
210    /// collection / operation / principal / tenant, then:
211    /// * [`Allow`](RawGateOutcome::Allow) — runs the search unmodified;
212    /// * [`Deny`](RawGateOutcome::Deny) — returns the supplied error and zero
213    ///   results;
214    /// * [`Scope`](RawGateOutcome::Scope) — AND-composes the scope filter with
215    ///   any caller filter before running the search.
216    ///
217    /// With no observer registered this is a single `Option` check followed by
218    /// the same leaf call the ungated path used (zero-overhead contract).
219    ///
220    /// # Errors
221    ///
222    /// Returns [`Error::CollectionNotFound`] if the collection does not exist,
223    /// the observer's `Deny` error when access is refused, or any error from the
224    /// underlying search leaf.
225    pub fn gated_search(
226        &self,
227        collection: &str,
228        principal: Option<&str>,
229        tenant_hint: Option<&str>,
230        read: GatedRead<'_>,
231    ) -> Result<Vec<SearchResult>> {
232        let scope_filter =
233            match self.read_gate_raw(collection, read.operation_kind(), principal, tenant_hint)? {
234                RawGateOutcome::Allow => None,
235                RawGateOutcome::Deny(err) => return Err(err),
236                RawGateOutcome::Scope(scope) => scope.filter.map(scope_to_core_filter),
237            };
238
239        let coll = self
240            .get_vector_collection(collection)
241            .ok_or_else(|| Error::CollectionNotFound(collection.to_string()))?;
242
243        dispatch_gated_read(&coll, read, scope_filter)
244    }
245}