sochdb_query/
namespace.rs

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