oxify_authz/
leopard.rs

1//! Leopard Indexing for optimized reachability queries
2//!
3//! Implements the Leopard indexing strategy from Google Zanzibar to achieve
4//! O(1) authorization checks for pre-computed relationship paths.
5//!
6//! ## How it works
7//!
8//! Instead of traversing the relationship graph at query time, Leopard pre-computes
9//! and materializes transitive closures for frequently accessed relationships.
10//!
11//! Example: If `owner` inherits `viewer`, and `alice` is an `owner` of `doc1`,
12//! the index stores both:
13//! - `(alice, owner, doc1)` - direct relationship
14//! - `(alice, viewer, doc1)` - computed/inherited relationship
15//!
16//! ## Performance
17//!
18//! - Reads: O(1) lookup in materialized index
19//! - Writes: O(n) where n is the depth of inheritance chain
20//! - Space: O(tuples × avg_inheritance_depth)
21
22use crate::*;
23use std::collections::{HashMap, HashSet};
24use std::sync::Arc;
25use tokio::sync::RwLock;
26
27/// Type alias for subject index: subject -> set of (namespace, object_id, relation)
28type SubjectIndex = Arc<RwLock<HashMap<String, HashSet<(String, String, String)>>>>;
29
30/// Type alias for object index: (namespace, object_id, relation) -> set of subjects
31type ObjectIndex = Arc<RwLock<HashMap<(String, String, String), HashSet<String>>>>;
32
33/// Type alias for direct tuples: set of (namespace, object_id, relation, subject)
34type DirectTuples = Arc<RwLock<HashSet<(String, String, String, String)>>>;
35
36/// Configuration for Leopard indexing
37#[derive(Debug, Clone)]
38pub struct LeopardConfig {
39    /// Maximum depth for transitive closure computation
40    pub max_depth: usize,
41    /// Whether to eagerly compute all transitive closures on startup
42    pub eager_compute: bool,
43    /// Namespaces to index (empty = all)
44    pub indexed_namespaces: Vec<String>,
45}
46
47impl Default for LeopardConfig {
48    fn default() -> Self {
49        Self {
50            max_depth: 10,
51            eager_compute: false,
52            indexed_namespaces: Vec::new(),
53        }
54    }
55}
56
57/// A materialized reachability entry
58#[derive(Debug, Clone, PartialEq, Eq, Hash)]
59pub struct ReachabilityEntry {
60    /// The subject that has access
61    pub subject: String,
62    /// The namespace of the object
63    pub namespace: String,
64    /// The object ID
65    pub object_id: String,
66    /// The relation (may be computed/inherited)
67    pub relation: String,
68    /// Depth in the inheritance chain (0 = direct)
69    pub depth: usize,
70    /// Whether this is a direct or computed relationship
71    pub is_computed: bool,
72}
73
74/// Statistics for Leopard index
75#[derive(Debug, Clone, Default)]
76pub struct LeopardStats {
77    /// Number of direct entries
78    pub direct_entries: u64,
79    /// Number of computed entries
80    pub computed_entries: u64,
81    /// Number of index hits
82    pub hits: u64,
83    /// Number of index misses
84    pub misses: u64,
85}
86
87impl LeopardStats {
88    /// Calculate hit rate
89    pub fn hit_rate(&self) -> f64 {
90        let total = self.hits + self.misses;
91        if total == 0 {
92            0.0
93        } else {
94            self.hits as f64 / total as f64
95        }
96    }
97
98    /// Total entries in index
99    pub fn total_entries(&self) -> u64 {
100        self.direct_entries + self.computed_entries
101    }
102}
103
104/// Leopard reachability index
105pub struct LeopardIndex {
106    /// Primary index: subject -> set of (namespace, object_id, relation)
107    by_subject: SubjectIndex,
108
109    /// Reverse index: (namespace, object_id, relation) -> set of subjects
110    by_object: ObjectIndex,
111
112    /// Direct tuples for tracking what needs recomputation
113    direct_tuples: DirectTuples,
114
115    /// Namespace configurations for inheritance rules
116    namespace_configs: Arc<HashMap<String, NamespaceConfig>>,
117
118    /// Configuration (reserved for future use)
119    #[allow(dead_code)]
120    config: LeopardConfig,
121
122    /// Statistics
123    stats: Arc<RwLock<LeopardStats>>,
124}
125
126impl LeopardIndex {
127    /// Create a new Leopard index
128    pub fn new(namespace_configs: Arc<HashMap<String, NamespaceConfig>>) -> Self {
129        Self::with_config(namespace_configs, LeopardConfig::default())
130    }
131
132    /// Create a new Leopard index with custom configuration
133    pub fn with_config(
134        namespace_configs: Arc<HashMap<String, NamespaceConfig>>,
135        config: LeopardConfig,
136    ) -> Self {
137        Self {
138            by_subject: Arc::new(RwLock::new(HashMap::new())),
139            by_object: Arc::new(RwLock::new(HashMap::new())),
140            direct_tuples: Arc::new(RwLock::new(HashSet::new())),
141            namespace_configs,
142            config,
143            stats: Arc::new(RwLock::new(LeopardStats::default())),
144        }
145    }
146
147    /// Index a new tuple and compute transitive closures
148    pub async fn index_tuple(&self, tuple: &RelationTuple) -> Result<usize> {
149        let subject_key = tuple.subject.to_string();
150
151        // Track direct tuple
152        {
153            let mut direct = self.direct_tuples.write().await;
154            direct.insert((
155                subject_key.clone(),
156                tuple.namespace.clone(),
157                tuple.object_id.clone(),
158                tuple.relation.clone(),
159            ));
160        }
161
162        // Add direct entry
163        self.add_entry(
164            &subject_key,
165            &tuple.namespace,
166            &tuple.object_id,
167            &tuple.relation,
168            false,
169        )
170        .await;
171
172        // Compute inherited relations
173        let mut computed_count = 0;
174        if let Some(ns_config) = self.namespace_configs.get(&tuple.namespace) {
175            // Find all relations that inherit from this relation
176            for rel_config in &ns_config.relations {
177                if rel_config.inherits_from.contains(&tuple.relation) {
178                    // This relation inherits from the tuple's relation
179                    // So the subject also has this relation
180                    self.add_entry(
181                        &subject_key,
182                        &tuple.namespace,
183                        &tuple.object_id,
184                        &rel_config.name,
185                        true,
186                    )
187                    .await;
188                    computed_count += 1;
189                }
190            }
191        }
192
193        Ok(computed_count + 1) // direct + computed
194    }
195
196    /// Remove a tuple and its computed entries from the index
197    pub async fn remove_tuple(&self, tuple: &RelationTuple) -> Result<usize> {
198        let subject_key = tuple.subject.to_string();
199
200        // Remove from direct tuples
201        {
202            let mut direct = self.direct_tuples.write().await;
203            direct.remove(&(
204                subject_key.clone(),
205                tuple.namespace.clone(),
206                tuple.object_id.clone(),
207                tuple.relation.clone(),
208            ));
209        }
210
211        // Remove direct entry
212        self.remove_entry(
213            &subject_key,
214            &tuple.namespace,
215            &tuple.object_id,
216            &tuple.relation,
217        )
218        .await;
219
220        // Remove inherited relations
221        let mut removed_count = 1;
222        if let Some(ns_config) = self.namespace_configs.get(&tuple.namespace) {
223            for rel_config in &ns_config.relations {
224                if rel_config.inherits_from.contains(&tuple.relation) {
225                    // Check if subject still has this inherited relation through another path
226                    let still_has = self
227                        .check_has_through_other_path(
228                            &subject_key,
229                            &tuple.namespace,
230                            &tuple.object_id,
231                            &rel_config.name,
232                            &tuple.relation,
233                        )
234                        .await;
235
236                    if !still_has {
237                        self.remove_entry(
238                            &subject_key,
239                            &tuple.namespace,
240                            &tuple.object_id,
241                            &rel_config.name,
242                        )
243                        .await;
244                        removed_count += 1;
245                    }
246                }
247            }
248        }
249
250        Ok(removed_count)
251    }
252
253    /// Check if a subject has a relation (O(1) lookup)
254    pub async fn check(&self, request: &CheckRequest) -> Option<bool> {
255        let subject_key = request.subject.to_string();
256        let key = (
257            request.namespace.clone(),
258            request.object_id.clone(),
259            request.relation.clone(),
260        );
261
262        let by_subject = self.by_subject.read().await;
263        if let Some(entries) = by_subject.get(&subject_key) {
264            let result = entries.contains(&key);
265
266            // Update stats
267            let mut stats = self.stats.write().await;
268            if result {
269                stats.hits += 1;
270            } else {
271                stats.misses += 1;
272            }
273
274            Some(result)
275        } else {
276            let mut stats = self.stats.write().await;
277            stats.misses += 1;
278            Some(false)
279        }
280    }
281
282    /// Get all subjects with a specific relation to an object
283    pub async fn expand(&self, namespace: &str, object_id: &str, relation: &str) -> Vec<String> {
284        let key = (
285            namespace.to_string(),
286            object_id.to_string(),
287            relation.to_string(),
288        );
289
290        let by_object = self.by_object.read().await;
291        by_object
292            .get(&key)
293            .map(|subjects| subjects.iter().cloned().collect())
294            .unwrap_or_default()
295    }
296
297    /// Get all relations a subject has to objects
298    pub async fn list_subject_access(&self, subject: &Subject) -> Vec<(String, String, String)> {
299        let subject_key = subject.to_string();
300
301        let by_subject = self.by_subject.read().await;
302        by_subject
303            .get(&subject_key)
304            .map(|entries| entries.iter().cloned().collect())
305            .unwrap_or_default()
306    }
307
308    /// Get statistics
309    pub async fn stats(&self) -> LeopardStats {
310        self.stats.read().await.clone()
311    }
312
313    /// Clear the index
314    pub async fn clear(&self) {
315        self.by_subject.write().await.clear();
316        self.by_object.write().await.clear();
317        self.direct_tuples.write().await.clear();
318        *self.stats.write().await = LeopardStats::default();
319    }
320
321    /// Bulk load tuples into the index
322    pub async fn bulk_load(&self, tuples: &[RelationTuple]) -> Result<usize> {
323        let mut total = 0;
324        for tuple in tuples {
325            total += self.index_tuple(tuple).await?;
326        }
327        Ok(total)
328    }
329
330    // Internal helper methods
331
332    async fn add_entry(
333        &self,
334        subject: &str,
335        namespace: &str,
336        object_id: &str,
337        relation: &str,
338        is_computed: bool,
339    ) {
340        let key = (
341            namespace.to_string(),
342            object_id.to_string(),
343            relation.to_string(),
344        );
345
346        // Add to subject index
347        {
348            let mut by_subject = self.by_subject.write().await;
349            by_subject
350                .entry(subject.to_string())
351                .or_default()
352                .insert(key.clone());
353        }
354
355        // Add to object index
356        {
357            let mut by_object = self.by_object.write().await;
358            by_object
359                .entry(key)
360                .or_default()
361                .insert(subject.to_string());
362        }
363
364        // Update stats
365        {
366            let mut stats = self.stats.write().await;
367            if is_computed {
368                stats.computed_entries += 1;
369            } else {
370                stats.direct_entries += 1;
371            }
372        }
373    }
374
375    async fn remove_entry(&self, subject: &str, namespace: &str, object_id: &str, relation: &str) {
376        let key = (
377            namespace.to_string(),
378            object_id.to_string(),
379            relation.to_string(),
380        );
381
382        // Remove from subject index
383        {
384            let mut by_subject = self.by_subject.write().await;
385            if let Some(entries) = by_subject.get_mut(subject) {
386                entries.remove(&key);
387                if entries.is_empty() {
388                    by_subject.remove(subject);
389                }
390            }
391        }
392
393        // Remove from object index
394        {
395            let mut by_object = self.by_object.write().await;
396            if let Some(subjects) = by_object.get_mut(&key) {
397                subjects.remove(subject);
398                if subjects.is_empty() {
399                    by_object.remove(&key);
400                }
401            }
402        }
403    }
404
405    async fn check_has_through_other_path(
406        &self,
407        subject: &str,
408        namespace: &str,
409        object_id: &str,
410        relation: &str,
411        excluded_source: &str,
412    ) -> bool {
413        // Check if the subject still has the relation through another inheritance path
414        if let Some(ns_config) = self.namespace_configs.get(namespace) {
415            if let Some(rel_config) = ns_config.relations.iter().find(|r| r.name == relation) {
416                let direct = self.direct_tuples.read().await;
417
418                // Check each possible source relation (except the excluded one)
419                for source_rel in &rel_config.inherits_from {
420                    if source_rel != excluded_source
421                        && direct.contains(&(
422                            subject.to_string(),
423                            namespace.to_string(),
424                            object_id.to_string(),
425                            source_rel.clone(),
426                        ))
427                    {
428                        return true;
429                    }
430                }
431            }
432        }
433        false
434    }
435}
436
437#[cfg(test)]
438mod tests {
439    use super::*;
440
441    fn test_namespace_configs() -> Arc<HashMap<String, NamespaceConfig>> {
442        let mut configs = HashMap::new();
443        configs.insert(
444            "document".to_string(),
445            NamespaceConfig::document_namespace(),
446        );
447        configs.insert("folder".to_string(), NamespaceConfig::folder_namespace());
448        Arc::new(configs)
449    }
450
451    #[tokio::test]
452    async fn test_leopard_direct_lookup() {
453        let configs = test_namespace_configs();
454        let index = LeopardIndex::new(configs);
455
456        // Add a direct relationship
457        let tuple = RelationTuple::new(
458            "document",
459            "owner",
460            "doc123",
461            Subject::User("alice".to_string()),
462        );
463        index.index_tuple(&tuple).await.unwrap();
464
465        // Check direct relationship
466        let request = CheckRequest {
467            namespace: "document".to_string(),
468            object_id: "doc123".to_string(),
469            relation: "owner".to_string(),
470            subject: Subject::User("alice".to_string()),
471            context: None,
472        };
473        assert_eq!(index.check(&request).await, Some(true));
474
475        // Check non-existent relationship
476        let request2 = CheckRequest {
477            namespace: "document".to_string(),
478            object_id: "doc123".to_string(),
479            relation: "owner".to_string(),
480            subject: Subject::User("bob".to_string()),
481            context: None,
482        };
483        assert_eq!(index.check(&request2).await, Some(false));
484    }
485
486    #[tokio::test]
487    async fn test_leopard_inherited_relations() {
488        let configs = test_namespace_configs();
489        let index = LeopardIndex::new(configs);
490
491        // Add owner relationship
492        let tuple = RelationTuple::new(
493            "document",
494            "owner",
495            "doc123",
496            Subject::User("alice".to_string()),
497        );
498        let count = index.index_tuple(&tuple).await.unwrap();
499
500        // Should have indexed direct + inherited (viewer inherits from owner)
501        assert!(count >= 2);
502
503        // Check inherited viewer relationship
504        let request = CheckRequest {
505            namespace: "document".to_string(),
506            object_id: "doc123".to_string(),
507            relation: "viewer".to_string(),
508            subject: Subject::User("alice".to_string()),
509            context: None,
510        };
511        assert_eq!(index.check(&request).await, Some(true));
512
513        // Check inherited editor relationship
514        let request2 = CheckRequest {
515            namespace: "document".to_string(),
516            object_id: "doc123".to_string(),
517            relation: "editor".to_string(),
518            subject: Subject::User("alice".to_string()),
519            context: None,
520        };
521        assert_eq!(index.check(&request2).await, Some(true));
522    }
523
524    #[tokio::test]
525    async fn test_leopard_remove_tuple() {
526        let configs = test_namespace_configs();
527        let index = LeopardIndex::new(configs);
528
529        // Add and then remove
530        let tuple = RelationTuple::new(
531            "document",
532            "owner",
533            "doc123",
534            Subject::User("alice".to_string()),
535        );
536        index.index_tuple(&tuple).await.unwrap();
537        index.remove_tuple(&tuple).await.unwrap();
538
539        // Should no longer have access
540        let request = CheckRequest {
541            namespace: "document".to_string(),
542            object_id: "doc123".to_string(),
543            relation: "owner".to_string(),
544            subject: Subject::User("alice".to_string()),
545            context: None,
546        };
547        assert_eq!(index.check(&request).await, Some(false));
548
549        // Inherited relations should also be gone
550        let request2 = CheckRequest {
551            namespace: "document".to_string(),
552            object_id: "doc123".to_string(),
553            relation: "viewer".to_string(),
554            subject: Subject::User("alice".to_string()),
555            context: None,
556        };
557        assert_eq!(index.check(&request2).await, Some(false));
558    }
559
560    #[tokio::test]
561    async fn test_leopard_expand() {
562        let configs = test_namespace_configs();
563        let index = LeopardIndex::new(configs);
564
565        // Add multiple users as viewers
566        for user in ["alice", "bob", "charlie"] {
567            let tuple = RelationTuple::new(
568                "document",
569                "viewer",
570                "doc123",
571                Subject::User(user.to_string()),
572            );
573            index.index_tuple(&tuple).await.unwrap();
574        }
575
576        // Expand should return all viewers
577        let viewers = index.expand("document", "doc123", "viewer").await;
578        assert_eq!(viewers.len(), 3);
579        assert!(viewers.contains(&"user:alice".to_string()));
580        assert!(viewers.contains(&"user:bob".to_string()));
581        assert!(viewers.contains(&"user:charlie".to_string()));
582    }
583
584    #[tokio::test]
585    async fn test_leopard_stats() {
586        let configs = test_namespace_configs();
587        let index = LeopardIndex::new(configs);
588
589        // Add some tuples
590        let tuple = RelationTuple::new(
591            "document",
592            "owner",
593            "doc123",
594            Subject::User("alice".to_string()),
595        );
596        index.index_tuple(&tuple).await.unwrap();
597
598        let stats = index.stats().await;
599        assert!(stats.direct_entries > 0);
600        assert!(stats.computed_entries > 0); // Due to inheritance
601
602        // Do some checks
603        let request = CheckRequest {
604            namespace: "document".to_string(),
605            object_id: "doc123".to_string(),
606            relation: "owner".to_string(),
607            subject: Subject::User("alice".to_string()),
608            context: None,
609        };
610        index.check(&request).await;
611        index.check(&request).await;
612
613        let stats = index.stats().await;
614        assert_eq!(stats.hits, 2);
615    }
616
617    #[tokio::test]
618    async fn test_leopard_bulk_load() {
619        let configs = test_namespace_configs();
620        let index = LeopardIndex::new(configs);
621
622        let tuples: Vec<RelationTuple> = (0..100)
623            .map(|i| {
624                RelationTuple::new(
625                    "document",
626                    "viewer",
627                    format!("doc{}", i),
628                    Subject::User(format!("user{}", i % 10)),
629                )
630            })
631            .collect();
632
633        let count = index.bulk_load(&tuples).await.unwrap();
634        assert_eq!(count, 100); // No inheritance for viewer
635
636        // Verify some entries
637        let request = CheckRequest {
638            namespace: "document".to_string(),
639            object_id: "doc50".to_string(),
640            relation: "viewer".to_string(),
641            subject: Subject::User("user0".to_string()),
642            context: None,
643        };
644        assert_eq!(index.check(&request).await, Some(true));
645    }
646}