oxify_authz/
bloom.rs

1//! Bloom filter for quick negative lookups
2//!
3//! This module implements a Bloom filter to reduce unnecessary database queries.
4//! The Bloom filter can definitively say "no" (tuple doesn't exist) but may
5//! have false positives for "yes" (tuple might exist, need to check DB).
6//!
7//! Expected reduction in DB queries: ~50% for non-existent tuples
8
9use crate::*;
10use bloomfilter::Bloom;
11use std::hash::{Hash, Hasher};
12use std::sync::RwLock;
13
14/// Configuration for the Bloom filter
15#[derive(Debug, Clone)]
16pub struct BloomConfig {
17    /// Expected number of items in the filter
18    pub expected_items: usize,
19    /// Target false positive rate (0.0 to 1.0)
20    pub false_positive_rate: f64,
21}
22
23impl Default for BloomConfig {
24    fn default() -> Self {
25        Self {
26            expected_items: 1_000_000, // 1M tuples
27            false_positive_rate: 0.01, // 1% false positive rate
28        }
29    }
30}
31
32/// Key for Bloom filter lookups
33#[derive(Debug, Clone, PartialEq, Eq)]
34struct BloomKey {
35    namespace: String,
36    object_id: String,
37    relation: String,
38    subject: String,
39}
40
41impl Hash for BloomKey {
42    fn hash<H: Hasher>(&self, state: &mut H) {
43        self.namespace.hash(state);
44        self.object_id.hash(state);
45        self.relation.hash(state);
46        self.subject.hash(state);
47    }
48}
49
50impl BloomKey {
51    fn new(namespace: &str, object_id: &str, relation: &str, subject: &Subject) -> Self {
52        Self {
53            namespace: namespace.to_string(),
54            object_id: object_id.to_string(),
55            relation: relation.to_string(),
56            subject: subject.to_string(),
57        }
58    }
59
60    fn from_tuple(tuple: &RelationTuple) -> Self {
61        Self::new(
62            &tuple.namespace,
63            &tuple.object_id,
64            &tuple.relation,
65            &tuple.subject,
66        )
67    }
68
69    fn from_check_request(request: &CheckRequest) -> Self {
70        Self::new(
71            &request.namespace,
72            &request.object_id,
73            &request.relation,
74            &request.subject,
75        )
76    }
77}
78
79/// Thread-safe Bloom filter for authorization tuples
80pub struct AuthzBloomFilter {
81    /// The underlying Bloom filter
82    filter: RwLock<Bloom<BloomKey>>,
83    /// Number of items added
84    items_count: RwLock<usize>,
85    /// Configuration
86    config: BloomConfig,
87}
88
89impl AuthzBloomFilter {
90    /// Create a new Bloom filter with default configuration
91    pub fn new() -> Self {
92        Self::with_config(BloomConfig::default())
93    }
94
95    /// Create a new Bloom filter with custom configuration
96    pub fn with_config(config: BloomConfig) -> Self {
97        let filter = Bloom::new_for_fp_rate(config.expected_items, config.false_positive_rate)
98            .expect("Failed to create bloom filter with given parameters");
99        Self {
100            filter: RwLock::new(filter),
101            items_count: RwLock::new(0),
102            config,
103        }
104    }
105
106    /// Add a tuple to the Bloom filter
107    pub fn add_tuple(&self, tuple: &RelationTuple) {
108        let key = BloomKey::from_tuple(tuple);
109        let mut filter = self.filter.write().unwrap();
110        filter.set(&key);
111        let mut count = self.items_count.write().unwrap();
112        *count += 1;
113    }
114
115    /// Check if a tuple might exist (true = might exist, false = definitely doesn't)
116    pub fn might_contain(&self, request: &CheckRequest) -> bool {
117        let key = BloomKey::from_check_request(request);
118        let filter = self.filter.read().unwrap();
119        filter.check(&key)
120    }
121
122    /// Batch check multiple requests
123    /// Returns a vector of booleans indicating which requests might have tuples
124    pub fn might_contain_batch(&self, requests: &[CheckRequest]) -> Vec<bool> {
125        let filter = self.filter.read().unwrap();
126        requests
127            .iter()
128            .map(|req| {
129                let key = BloomKey::from_check_request(req);
130                filter.check(&key)
131            })
132            .collect()
133    }
134
135    /// Get the current item count
136    pub fn item_count(&self) -> usize {
137        *self.items_count.read().unwrap()
138    }
139
140    /// Clear the Bloom filter (requires rebuilding)
141    pub fn clear(&self) {
142        let new_filter =
143            Bloom::new_for_fp_rate(self.config.expected_items, self.config.false_positive_rate)
144                .expect("Failed to create bloom filter with given parameters");
145        *self.filter.write().unwrap() = new_filter;
146        *self.items_count.write().unwrap() = 0;
147    }
148
149    /// Get estimated false positive rate based on current fill
150    pub fn estimated_fp_rate(&self) -> f64 {
151        let count = *self.items_count.read().unwrap();
152        if count == 0 {
153            return 0.0;
154        }
155
156        // Approximation: fp_rate increases as filter fills up
157        let fill_ratio = count as f64 / self.config.expected_items as f64;
158        if fill_ratio >= 1.0 {
159            // Filter is at or over capacity, FP rate is high
160            return 0.5;
161        }
162
163        // Scale the base FP rate by fill ratio (simplified model)
164        self.config.false_positive_rate * (1.0 + fill_ratio)
165    }
166}
167
168impl Default for AuthzBloomFilter {
169    fn default() -> Self {
170        Self::new()
171    }
172}
173
174/// Statistics for Bloom filter performance
175#[derive(Debug, Clone, Default)]
176pub struct BloomStats {
177    /// Number of definite negatives (avoided DB queries)
178    pub definite_negatives: u64,
179    /// Number of potential positives (required DB queries)
180    pub potential_positives: u64,
181    /// Number of true positives (confirmed by DB)
182    pub true_positives: u64,
183    /// Number of false positives (not found in DB despite Bloom saying yes)
184    pub false_positives: u64,
185}
186
187impl BloomStats {
188    /// Calculate the DB query reduction rate
189    pub fn query_reduction_rate(&self) -> f64 {
190        let total = self.definite_negatives + self.potential_positives;
191        if total == 0 {
192            return 0.0;
193        }
194        self.definite_negatives as f64 / total as f64
195    }
196
197    /// Calculate the actual false positive rate
198    pub fn actual_fp_rate(&self) -> f64 {
199        let db_checks = self.true_positives + self.false_positives;
200        if db_checks == 0 {
201            return 0.0;
202        }
203        self.false_positives as f64 / db_checks as f64
204    }
205}
206
207#[cfg(test)]
208mod tests {
209    use super::*;
210
211    #[test]
212    fn test_bloom_filter_basic() {
213        let bloom = AuthzBloomFilter::with_config(BloomConfig {
214            expected_items: 1000,
215            false_positive_rate: 0.01,
216        });
217
218        // Add a tuple
219        let tuple = RelationTuple::new(
220            "document",
221            "viewer",
222            "doc123",
223            Subject::User("alice".to_string()),
224        );
225        bloom.add_tuple(&tuple);
226
227        // Should find it
228        let request = CheckRequest {
229            namespace: "document".to_string(),
230            object_id: "doc123".to_string(),
231            relation: "viewer".to_string(),
232            subject: Subject::User("alice".to_string()),
233            context: None,
234        };
235        assert!(bloom.might_contain(&request));
236
237        // Should NOT find a different tuple (with high probability)
238        let request2 = CheckRequest {
239            namespace: "document".to_string(),
240            object_id: "doc999".to_string(),
241            relation: "owner".to_string(),
242            subject: Subject::User("bob".to_string()),
243            context: None,
244        };
245        // Note: This could be a false positive, but with 1% FP rate it's unlikely
246        // In practice, we'd need many iterations to test this statistically
247        let _result = bloom.might_contain(&request2);
248    }
249
250    #[test]
251    fn test_bloom_filter_batch() {
252        let bloom = AuthzBloomFilter::with_config(BloomConfig {
253            expected_items: 1000,
254            false_positive_rate: 0.01,
255        });
256
257        // Add some tuples
258        for i in 0..10 {
259            let tuple = RelationTuple::new(
260                "document",
261                "viewer",
262                format!("doc{}", i),
263                Subject::User("alice".to_string()),
264            );
265            bloom.add_tuple(&tuple);
266        }
267
268        // Batch check
269        let requests: Vec<_> = (0..15)
270            .map(|i| CheckRequest {
271                namespace: "document".to_string(),
272                object_id: format!("doc{}", i),
273                relation: "viewer".to_string(),
274                subject: Subject::User("alice".to_string()),
275                context: None,
276            })
277            .collect();
278
279        let results = bloom.might_contain_batch(&requests);
280        assert_eq!(results.len(), 15);
281
282        // First 10 should definitely be true (we added them)
283        for result in results.iter().take(10) {
284            assert!(*result);
285        }
286    }
287
288    #[test]
289    fn test_bloom_stats() {
290        let stats = BloomStats {
291            definite_negatives: 500,
292            potential_positives: 500,
293            true_positives: 450,
294            false_positives: 50,
295        };
296
297        assert!((stats.query_reduction_rate() - 0.5).abs() < 0.001);
298        assert!((stats.actual_fp_rate() - 0.1).abs() < 0.001);
299    }
300
301    #[test]
302    fn test_bloom_clear() {
303        let bloom = AuthzBloomFilter::with_config(BloomConfig {
304            expected_items: 1000,
305            false_positive_rate: 0.01,
306        });
307
308        // Add a tuple
309        let tuple = RelationTuple::new(
310            "document",
311            "viewer",
312            "doc123",
313            Subject::User("alice".to_string()),
314        );
315        bloom.add_tuple(&tuple);
316        assert_eq!(bloom.item_count(), 1);
317
318        // Clear
319        bloom.clear();
320        assert_eq!(bloom.item_count(), 0);
321    }
322}