Skip to main content

sochdb_query/
namespace.rs

1// SPDX-License-Identifier: AGPL-3.0-or-later
2// SochDB - LLM-Optimized Embedded Database
3// Copyright (C) 2026 Sushanth Reddy Vanagala (https://github.com/sushanthpy)
4//
5// This program is free software: you can redistribute it and/or modify
6// it under the terms of the GNU Affero General Public License as published by
7// the Free Software Foundation, either version 3 of the License, or
8// (at your option) any later version.
9//
10// This program is distributed in the hope that it will be useful,
11// but WITHOUT ANY WARRANTY; without even the implied warranty of
12// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13// GNU Affero General Public License for more details.
14//
15// You should have received a copy of the GNU Affero General Public License
16// along with this program. If not, see <https://www.gnu.org/licenses/>.
17
18//! Namespace-Scoped Query API (Task 2)
19//!
20//! This module enforces **mandatory namespace scoping** at the type level,
21//! making cross-workspace data leakage impossible by construction.
22//!
23//! ## The Problem
24//!
25//! When namespace/tenant scoping is treated as an optional filter parameter,
26//! developers can accidentally:
27//! - Query across workspaces by forgetting to add the namespace filter
28//! - Reuse a handle across workspaces in local-first scenarios
29//! - Mix data from different tenants in multi-tenant deployments
30//!
31//! ## The Solution
32//!
33//! Make `namespace` a **required part of the query identity**, not an
34//! optional filter. The type system enforces:
35//!
36//! 1. `Namespace` is required in every query request
37//! 2. `Namespace` must be validated against the capability token
38//! 3. "No namespace" is not a valid state
39//!
40//! ## Multi-Namespace Queries
41//!
42//! For legitimate multi-namespace queries, use `NamespaceScope::Multiple`
43//! which requires explicit authorization for each namespace.
44//!
45//! ## Example
46//!
47//! ```ignore
48//! // This compiles - namespace is required
49//! let query = ScopedQuery::new(
50//!     Namespace::new("production"),
51//!     QueryOp::VectorSearch { ... }
52//! );
53//!
54//! // This won't compile - no namespace
55//! let query = ScopedQuery::new(QueryOp::VectorSearch { ... });  // ERROR!
56//! ```
57
58use std::fmt;
59use std::sync::Arc;
60
61use serde::{Deserialize, Serialize};
62
63use crate::filter_ir::{AuthScope, FilterIR, FilterBuilder};
64
65// ============================================================================
66// Namespace - Opaque, Validated Identifier
67// ============================================================================
68
69/// A validated namespace identifier
70///
71/// This is an opaque type that can only be constructed via validation,
72/// preventing accidental use of invalid namespace strings.
73#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
74pub struct Namespace(String);
75
76impl Namespace {
77    /// Maximum length for a namespace identifier
78    pub const MAX_LENGTH: usize = 256;
79    
80    /// Create a new namespace (validates format)
81    ///
82    /// # Validation Rules
83    /// - Non-empty
84    /// - Max 256 characters
85    /// - Alphanumeric, underscores, hyphens, and periods only
86    /// - Cannot start with a period or hyphen
87    pub fn new(name: impl Into<String>) -> Result<Self, NamespaceError> {
88        let name = name.into();
89        Self::validate(&name)?;
90        Ok(Self(name))
91    }
92    
93    /// Create without validation (for internal use only)
94    ///
95    /// # Safety
96    /// Caller must ensure the name is valid.
97    #[allow(dead_code)]
98    pub(crate) fn new_unchecked(name: impl Into<String>) -> Self {
99        Self(name.into())
100    }
101    
102    /// Validate a namespace string
103    fn validate(name: &str) -> Result<(), NamespaceError> {
104        if name.is_empty() {
105            return Err(NamespaceError::Empty);
106        }
107        
108        if name.len() > Self::MAX_LENGTH {
109            return Err(NamespaceError::TooLong {
110                length: name.len(),
111                max: Self::MAX_LENGTH,
112            });
113        }
114        
115        // Check first character
116        let first = name.chars().next().unwrap();
117        if first == '.' || first == '-' {
118            return Err(NamespaceError::InvalidStart(first));
119        }
120        
121        // Check all characters
122        for (i, ch) in name.chars().enumerate() {
123            if !ch.is_alphanumeric() && ch != '_' && ch != '-' && ch != '.' {
124                return Err(NamespaceError::InvalidChar { ch, position: i });
125            }
126        }
127        
128        Ok(())
129    }
130    
131    /// Get the namespace as a string slice
132    pub fn as_str(&self) -> &str {
133        &self.0
134    }
135    
136    /// Convert to owned string
137    pub fn into_string(self) -> String {
138        self.0
139    }
140}
141
142impl fmt::Display for Namespace {
143    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
144        write!(f, "{}", self.0)
145    }
146}
147
148impl AsRef<str> for Namespace {
149    fn as_ref(&self) -> &str {
150        &self.0
151    }
152}
153
154/// Errors that can occur when creating a namespace
155#[derive(Debug, Clone, thiserror::Error)]
156pub enum NamespaceError {
157    #[error("namespace cannot be empty")]
158    Empty,
159    
160    #[error("namespace too long: {length} > {max}")]
161    TooLong { length: usize, max: usize },
162    
163    #[error("namespace cannot start with '{0}'")]
164    InvalidStart(char),
165    
166    #[error("invalid character '{ch}' at position {position}")]
167    InvalidChar { ch: char, position: usize },
168}
169
170// ============================================================================
171// Namespace Scope - Single or Multiple
172// ============================================================================
173
174/// Scope for a query - either single namespace or explicitly multiple
175#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
176pub enum NamespaceScope {
177    /// Query within a single namespace (most common)
178    Single(Namespace),
179    
180    /// Query across multiple namespaces (requires explicit authorization)
181    Multiple(Vec<Namespace>),
182}
183
184impl NamespaceScope {
185    /// Create a single-namespace scope
186    pub fn single(ns: Namespace) -> Self {
187        Self::Single(ns)
188    }
189    
190    /// Create a multi-namespace scope
191    pub fn multiple(namespaces: Vec<Namespace>) -> Result<Self, NamespaceError> {
192        if namespaces.is_empty() {
193            return Err(NamespaceError::Empty);
194        }
195        Ok(Self::Multiple(namespaces))
196    }
197    
198    /// Get all namespaces in this scope
199    pub fn namespaces(&self) -> Vec<&Namespace> {
200        match self {
201            Self::Single(ns) => vec![ns],
202            Self::Multiple(nss) => nss.iter().collect(),
203        }
204    }
205    
206    /// Check if a namespace is in this scope
207    pub fn contains(&self, ns: &Namespace) -> bool {
208        match self {
209            Self::Single(single) => single == ns,
210            Self::Multiple(multiple) => multiple.contains(ns),
211        }
212    }
213    
214    /// Validate against an auth scope
215    pub fn validate_against(&self, auth: &AuthScope) -> Result<(), ScopeError> {
216        for ns in self.namespaces() {
217            if !auth.is_namespace_allowed(ns.as_str()) {
218                return Err(ScopeError::NamespaceNotAllowed(ns.clone()));
219            }
220        }
221        Ok(())
222    }
223    
224    /// Convert to filter IR clauses
225    pub fn to_filter_ir(&self) -> FilterIR {
226        match self {
227            Self::Single(ns) => FilterBuilder::new()
228                .namespace(ns.as_str())
229                .build(),
230            Self::Multiple(nss) => {
231                use crate::filter_ir::{FilterAtom, FilterValue};
232                FilterIR::from_atom(FilterAtom::in_set(
233                    "namespace",
234                    nss.iter()
235                        .map(|ns| FilterValue::String(ns.as_str().to_string()))
236                        .collect(),
237                ))
238            }
239        }
240    }
241}
242
243impl fmt::Display for NamespaceScope {
244    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
245        match self {
246            Self::Single(ns) => write!(f, "{}", ns),
247            Self::Multiple(nss) => {
248                let names: Vec<_> = nss.iter().map(|ns| ns.as_str()).collect();
249                write!(f, "[{}]", names.join(", "))
250            }
251        }
252    }
253}
254
255/// Errors related to namespace scope
256#[derive(Debug, Clone, thiserror::Error)]
257pub enum ScopeError {
258    #[error("namespace not allowed: {0}")]
259    NamespaceNotAllowed(Namespace),
260    
261    #[error("auth scope expired")]
262    AuthExpired,
263    
264    #[error("insufficient capabilities for this operation")]
265    InsufficientCapabilities,
266}
267
268// ============================================================================
269// Scoped Query - Query with Mandatory Namespace
270// ============================================================================
271
272/// A query that is always scoped to a namespace
273///
274/// This type makes cross-workspace queries impossible by construction.
275/// Every query MUST specify a namespace scope.
276#[derive(Debug, Clone)]
277pub struct ScopedQuery<Q> {
278    /// The namespace scope (mandatory)
279    scope: NamespaceScope,
280    
281    /// The underlying query operation
282    query: Q,
283    
284    /// User-provided filters (in addition to namespace)
285    filters: FilterIR,
286}
287
288impl<Q> ScopedQuery<Q> {
289    /// Create a new scoped query
290    ///
291    /// The namespace scope is required - this is the key invariant.
292    pub fn new(scope: NamespaceScope, query: Q) -> Self {
293        Self {
294            scope,
295            query,
296            filters: FilterIR::all(),
297        }
298    }
299    
300    /// Create a single-namespace query (convenience)
301    pub fn in_namespace(namespace: Namespace, query: Q) -> Self {
302        Self::new(NamespaceScope::Single(namespace), query)
303    }
304    
305    /// Add user filters
306    pub fn with_filters(mut self, filters: FilterIR) -> Self {
307        self.filters = filters;
308        self
309    }
310    
311    /// Get the namespace scope
312    pub fn scope(&self) -> &NamespaceScope {
313        &self.scope
314    }
315    
316    /// Get the underlying query
317    pub fn query(&self) -> &Q {
318        &self.query
319    }
320    
321    /// Get user filters
322    pub fn filters(&self) -> &FilterIR {
323        &self.filters
324    }
325    
326    /// Compute the effective filter (namespace + user filters)
327    ///
328    /// This is the filter that will be passed to executors.
329    pub fn effective_filter(&self) -> FilterIR {
330        self.scope.to_filter_ir().and(self.filters.clone())
331    }
332    
333    /// Validate this query against an auth scope
334    pub fn validate(&self, auth: &AuthScope) -> Result<(), ScopeError> {
335        // Check auth expiry
336        if auth.is_expired() {
337            return Err(ScopeError::AuthExpired);
338        }
339        
340        // Check namespace access
341        self.scope.validate_against(auth)?;
342        
343        Ok(())
344    }
345    
346    /// Extract the query, consuming self
347    pub fn into_query(self) -> Q {
348        self.query
349    }
350}
351
352// ============================================================================
353// Query Request - Complete Request with Auth
354// ============================================================================
355
356/// A complete query request with authentication
357///
358/// This is the type that crosses API boundaries. It bundles:
359/// - The scoped query (with mandatory namespace)
360/// - The auth scope (with capability token)
361///
362/// This makes it impossible to execute a query without proper auth.
363#[derive(Debug, Clone)]
364pub struct QueryRequest<Q> {
365    /// The scoped query
366    query: ScopedQuery<Q>,
367    
368    /// The auth scope (from capability token)
369    auth: Arc<AuthScope>,
370}
371
372impl<Q> QueryRequest<Q> {
373    /// Create a new query request
374    ///
375    /// # Validation
376    /// This validates the query scope against the auth scope at construction time.
377    pub fn new(query: ScopedQuery<Q>, auth: Arc<AuthScope>) -> Result<Self, ScopeError> {
378        query.validate(&auth)?;
379        Ok(Self { query, auth })
380    }
381    
382    /// Get the scoped query
383    pub fn query(&self) -> &ScopedQuery<Q> {
384        &self.query
385    }
386    
387    /// Get the auth scope
388    pub fn auth(&self) -> &AuthScope {
389        &self.auth
390    }
391    
392    /// Compute the complete effective filter
393    ///
394    /// This combines:
395    /// 1. Auth scope constraints (mandatory)
396    /// 2. Namespace scope constraints (mandatory)  
397    /// 3. User-provided filters (optional)
398    pub fn effective_filter(&self) -> FilterIR {
399        self.auth.to_filter_ir()
400            .and(self.query.effective_filter())
401    }
402    
403    /// Get the namespace scope
404    pub fn namespace_scope(&self) -> &NamespaceScope {
405        self.query.scope()
406    }
407}
408
409// ============================================================================
410// Convenience Constructors
411// ============================================================================
412
413/// Create a namespace (shorthand)
414pub fn ns(name: &str) -> Result<Namespace, NamespaceError> {
415    Namespace::new(name)
416}
417
418/// Create a single-namespace scope (shorthand)
419pub fn scope(name: &str) -> Result<NamespaceScope, NamespaceError> {
420    Ok(NamespaceScope::Single(Namespace::new(name)?))
421}
422
423// ============================================================================
424// Tests
425// ============================================================================
426
427#[cfg(test)]
428mod tests {
429    use super::*;
430    
431    #[test]
432    fn test_namespace_validation() {
433        // Valid
434        assert!(Namespace::new("production").is_ok());
435        assert!(Namespace::new("my_namespace").is_ok());
436        assert!(Namespace::new("project-123").is_ok());
437        assert!(Namespace::new("v1.0.0").is_ok());
438        
439        // Invalid
440        assert!(Namespace::new("").is_err()); // Empty
441        assert!(Namespace::new("-starts-with-dash").is_err());
442        assert!(Namespace::new(".starts-with-dot").is_err());
443        assert!(Namespace::new("has spaces").is_err());
444        assert!(Namespace::new("has@symbol").is_err());
445    }
446    
447    #[test]
448    fn test_namespace_scope_single() {
449        let ns = Namespace::new("production").unwrap();
450        let scope = NamespaceScope::single(ns.clone());
451        
452        assert!(scope.contains(&ns));
453        assert!(!scope.contains(&Namespace::new("staging").unwrap()));
454    }
455    
456    #[test]
457    fn test_namespace_scope_multiple() {
458        let ns1 = Namespace::new("prod").unwrap();
459        let ns2 = Namespace::new("staging").unwrap();
460        let scope = NamespaceScope::multiple(vec![ns1.clone(), ns2.clone()]).unwrap();
461        
462        assert!(scope.contains(&ns1));
463        assert!(scope.contains(&ns2));
464        assert!(!scope.contains(&Namespace::new("dev").unwrap()));
465    }
466    
467    #[test]
468    fn test_scope_to_filter_ir() {
469        let scope = NamespaceScope::single(Namespace::new("production").unwrap());
470        let filter = scope.to_filter_ir();
471        
472        assert!(filter.constrains_field("namespace"));
473        assert_eq!(filter.clauses.len(), 1);
474    }
475    
476    #[test]
477    fn test_scoped_query_effective_filter() {
478        let ns = Namespace::new("production").unwrap();
479        let user_filter = FilterBuilder::new()
480            .eq("source", "documents")
481            .build();
482        
483        let query: ScopedQuery<()> = ScopedQuery::in_namespace(ns, ())
484            .with_filters(user_filter);
485        
486        let effective = query.effective_filter();
487        assert!(effective.constrains_field("namespace"));
488        assert!(effective.constrains_field("source"));
489    }
490    
491    #[test]
492    fn test_query_request_validation() {
493        let ns = Namespace::new("production").unwrap();
494        let query: ScopedQuery<()> = ScopedQuery::in_namespace(ns, ());
495        
496        // Auth allows production
497        let auth = Arc::new(AuthScope::for_namespace("production"));
498        assert!(QueryRequest::new(query.clone(), auth).is_ok());
499        
500        // Auth only allows staging
501        let auth2 = Arc::new(AuthScope::for_namespace("staging"));
502        assert!(QueryRequest::new(query, auth2).is_err());
503    }
504    
505    #[test]
506    fn test_query_request_effective_filter() {
507        let ns = Namespace::new("production").unwrap();
508        let query: ScopedQuery<()> = ScopedQuery::in_namespace(ns, ())
509            .with_filters(FilterBuilder::new().eq("type", "article").build());
510        
511        let auth = Arc::new(
512            AuthScope::for_namespace("production")
513                .with_tenant("acme")
514        );
515        
516        let request = QueryRequest::new(query, auth).unwrap();
517        let effective = request.effective_filter();
518        
519        // Should have: namespace (from scope) + tenant_id (from auth) + type (from user)
520        assert!(effective.constrains_field("namespace"));
521        assert!(effective.constrains_field("tenant_id"));
522        assert!(effective.constrains_field("type"));
523    }
524}