Skip to main content

auth_framework/protocols/
zanzibar.rs

1//! Google Zanzibar–inspired Relationship-Based Access Control (ReBAC).
2//!
3//! Implements a tuple-based authorization model where access decisions are derived
4//! from relationships between subjects and objects, enabling questions like
5//! "does User X own Folder Y that contains Document Z?"
6//!
7//! # Core Concepts
8//!
9//! - **Tuple** — `(object, relation, subject)` e.g. `("doc:readme", "viewer", "user:alice")`
10//! - **Namespace** — a type of object (e.g. `document`, `folder`, `group`)
11//! - **Relation** — named edge between an object and a subject (e.g. `owner`, `viewer`)
12//! - **Userset rewrite** — indirect relationships via union, intersection, or computed paths
13//!
14//! # References
15//!
16//! - [Zanzibar: Google's Consistent, Global Authorization System](https://research.google/pubs/pub48190/)
17//! - [OpenFGA / Zanzibar model](https://openfga.dev/docs/concepts)
18
19use crate::errors::{AuthError, Result};
20use serde::{Deserialize, Serialize};
21use std::collections::{HashMap, HashSet, VecDeque};
22use std::sync::Arc;
23use tokio::sync::RwLock;
24
25// ── Relation tuple ──────────────────────────────────────────────────
26
27/// A single relationship tuple: (object, relation, subject).
28#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
29pub struct RelationTuple {
30    /// Object identifier (e.g. "document:readme").
31    pub object: String,
32    /// Relation name (e.g. "viewer", "owner", "parent").
33    pub relation: String,
34    /// Subject — either a direct user ID or a userset reference
35    /// (e.g. "user:alice" or "group:engineering#member").
36    pub subject: String,
37}
38
39impl RelationTuple {
40    /// Create a new relation tuple.
41    pub fn new(
42        object: impl Into<String>,
43        relation: impl Into<String>,
44        subject: impl Into<String>,
45    ) -> Self {
46        Self {
47            object: object.into(),
48            relation: relation.into(),
49            subject: subject.into(),
50        }
51    }
52
53    /// Parse the subject's namespace and optional userset relation.
54    ///
55    /// `"user:alice"` → `("user", "alice", None)`
56    /// `"group:eng#member"` → `("group", "eng", Some("member"))`
57    pub fn parse_subject(&self) -> Option<(&str, &str, Option<&str>)> {
58        let (ns_id, userset) = if let Some(hash_pos) = self.subject.find('#') {
59            (&self.subject[..hash_pos], Some(&self.subject[hash_pos + 1..]))
60        } else {
61            (self.subject.as_str(), None)
62        };
63        let colon_pos = ns_id.find(':')?;
64        Some((&ns_id[..colon_pos], &ns_id[colon_pos + 1..], userset))
65    }
66
67    /// Parse the object's namespace and ID.
68    ///
69    /// `"document:readme"` → `("document", "readme")`
70    pub fn parse_object(&self) -> Option<(&str, &str)> {
71        let pos = self.object.find(':')?;
72        Some((&self.object[..pos], &self.object[pos + 1..]))
73    }
74}
75
76// ── Namespace configuration ─────────────────────────────────────────
77
78/// Defines the valid relations for a namespace and how they compute.
79#[derive(Debug, Clone, Serialize, Deserialize)]
80pub struct NamespaceConfig {
81    /// Namespace name (e.g. "document").
82    pub name: String,
83    /// Direct relation definitions.
84    pub relations: HashMap<String, RelationDef>,
85}
86
87/// Definition of a relation within a namespace.
88#[derive(Debug, Clone, Serialize, Deserialize)]
89pub struct RelationDef {
90    /// Direct assignment allowed.
91    #[serde(default = "default_true")]
92    pub direct: bool,
93    /// Compute via union of other relation's subjects.
94    /// e.g. `viewer` includes all `editor` subjects.
95    #[serde(default)]
96    pub union: Vec<String>,
97    /// Compute via a tuple-to-userset rewrite.
98    /// e.g. `viewer` includes `parent#viewer` — traverse the `parent` relation
99    /// on the object, then check `viewer` on those parent objects.
100    #[serde(default)]
101    pub tuple_to_userset: Vec<TupleToUserset>,
102}
103
104fn default_true() -> bool {
105    true
106}
107
108/// A tuple-to-userset rewrite rule.
109#[derive(Debug, Clone, Serialize, Deserialize)]
110pub struct TupleToUserset {
111    /// The relation to traverse on the current object (e.g. "parent").
112    pub tupleset_relation: String,
113    /// The relation to check on the resolved object (e.g. "viewer").
114    pub computed_userset_relation: String,
115}
116
117// ── Zanzibar Store ──────────────────────────────────────────────────
118
119/// In-memory Zanzibar-style relationship store with namespace configuration.
120pub struct ZanzibarStore {
121    /// Namespace configurations.
122    namespaces: Arc<RwLock<HashMap<String, NamespaceConfig>>>,
123    /// All stored relation tuples, indexed by object.
124    tuples: Arc<RwLock<HashMap<String, Vec<RelationTuple>>>>,
125    /// Maximum graph traversal depth to prevent cycles.
126    max_depth: usize,
127}
128
129impl ZanzibarStore {
130    /// Create a new Zanzibar store with the given max traversal depth.
131    pub fn new(max_depth: usize) -> Self {
132        Self {
133            namespaces: Arc::new(RwLock::new(HashMap::new())),
134            tuples: Arc::new(RwLock::new(HashMap::new())),
135            max_depth,
136        }
137    }
138
139    /// Register a namespace configuration.
140    pub async fn add_namespace(&self, config: NamespaceConfig) {
141        self.namespaces
142            .write()
143            .await
144            .insert(config.name.clone(), config);
145    }
146
147    /// Write a relation tuple.
148    pub async fn write_tuple(&self, tuple: RelationTuple) -> Result<()> {
149        // Validate namespace/relation
150        if let Some((ns, _)) = tuple.parse_object() {
151            let namespaces = self.namespaces.read().await;
152            if let Some(ns_config) = namespaces.get(ns) {
153                if !ns_config.relations.contains_key(&tuple.relation) {
154                    return Err(AuthError::validation(&format!(
155                        "Relation '{}' not defined in namespace '{}'",
156                        tuple.relation, ns
157                    )));
158                }
159            }
160        }
161
162        self.tuples
163            .write()
164            .await
165            .entry(tuple.object.clone())
166            .or_default()
167            .push(tuple);
168        Ok(())
169    }
170
171    /// Delete a specific relation tuple.
172    pub async fn delete_tuple(&self, tuple: &RelationTuple) -> bool {
173        let mut tuples = self.tuples.write().await;
174        if let Some(list) = tuples.get_mut(&tuple.object) {
175            let before = list.len();
176            list.retain(|t| t != tuple);
177            list.len() < before
178        } else {
179            false
180        }
181    }
182
183    /// Read all tuples for an object, optionally filtered by relation.
184    pub async fn read_tuples(
185        &self,
186        object: &str,
187        relation: Option<&str>,
188    ) -> Vec<RelationTuple> {
189        let tuples = self.tuples.read().await;
190        match tuples.get(object) {
191            Some(list) => {
192                if let Some(rel) = relation {
193                    list.iter().filter(|t| t.relation == rel).cloned().collect()
194                } else {
195                    list.clone()
196                }
197            }
198            None => Vec::new(),
199        }
200    }
201
202    /// **Check** — the core Zanzibar operation.
203    ///
204    /// Determines whether `subject` has the `relation` on `object` by
205    /// traversing direct tuples, union rewrites, and tuple-to-userset paths.
206    pub async fn check(
207        &self,
208        object: &str,
209        relation: &str,
210        subject: &str,
211    ) -> Result<bool> {
212        let mut visited = HashSet::new();
213        self.check_internal(object, relation, subject, 0, &mut visited)
214            .await
215    }
216
217    #[allow(clippy::only_used_in_recursion)]
218    fn check_internal<'a>(
219        &'a self,
220        object: &'a str,
221        relation: &'a str,
222        subject: &'a str,
223        depth: usize,
224        visited: &'a mut HashSet<String>,
225    ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<bool>> + Send + 'a>> {
226        Box::pin(async move {
227        if depth > self.max_depth {
228            return Err(AuthError::internal(
229                "Zanzibar check exceeded maximum traversal depth",
230            ));
231        }
232
233        let visit_key = format!("{object}#{relation}@{subject}");
234        if !visited.insert(visit_key) {
235            return Ok(false); // Cycle detected
236        }
237
238        // 1. Direct check
239        let direct_tuples = self.read_tuples(object, Some(relation)).await;
240        for t in &direct_tuples {
241            if t.subject == subject {
242                return Ok(true);
243            }
244
245            // Userset reference: e.g. subject = "group:eng#member"
246            if let Some((_, sub_id, Some(sub_rel))) = t.parse_subject() {
247                let (sub_ns, _) = t.subject.split_once('#').unwrap_or((&t.subject, ""));
248                // Check if `subject` has `sub_rel` on the referenced object
249                if self
250                    .check_internal(sub_ns, sub_rel, subject, depth + 1, visited)
251                    .await?
252                {
253                    let _ = sub_id; // used for the userset resolution
254                    return Ok(true);
255                }
256            }
257        }
258
259        // 2. Union rewrites
260        if let Some((ns, _)) = object.split_once(':') {
261            let namespaces = self.namespaces.read().await;
262            if let Some(ns_config) = namespaces.get(ns) {
263                if let Some(rel_def) = ns_config.relations.get(relation) {
264                    // Check union relations
265                    for union_rel in &rel_def.union {
266                        if self
267                            .check_internal(object, union_rel, subject, depth + 1, visited)
268                            .await?
269                        {
270                            return Ok(true);
271                        }
272                    }
273
274                    // 3. Tuple-to-userset rewrites
275                    for ttu in &rel_def.tuple_to_userset {
276                        let parent_tuples = self
277                            .read_tuples(object, Some(&ttu.tupleset_relation))
278                            .await;
279                        for pt in &parent_tuples {
280                            if self
281                                .check_internal(
282                                    &pt.subject,
283                                    &ttu.computed_userset_relation,
284                                    subject,
285                                    depth + 1,
286                                    visited,
287                                )
288                                .await?
289                            {
290                                return Ok(true);
291                            }
292                        }
293                    }
294                }
295            }
296        }
297
298        Ok(false)
299    })
300    }
301
302    /// **Expand** — list all subjects that have a given relation on an object.
303    pub async fn expand(
304        &self,
305        object: &str,
306        relation: &str,
307    ) -> Result<Vec<String>> {
308        let mut result = Vec::new();
309        let mut visited = HashSet::new();
310        self.expand_internal(object, relation, 0, &mut result, &mut visited)
311            .await?;
312        Ok(result)
313    }
314
315    fn expand_internal<'a>(
316        &'a self,
317        object: &'a str,
318        relation: &'a str,
319        depth: usize,
320        result: &'a mut Vec<String>,
321        visited: &'a mut HashSet<String>,
322    ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<()>> + Send + 'a>> {
323        Box::pin(async move {
324        if depth > self.max_depth {
325            return Ok(());
326        }
327
328        let visit_key = format!("{object}#{relation}");
329        if !visited.insert(visit_key) {
330            return Ok(());
331        }
332
333        // Direct subjects
334        let tuples = self.read_tuples(object, Some(relation)).await;
335        for t in &tuples {
336            if t.subject.contains('#') {
337                // Userset: expand the referenced object's relation
338                let (ref_obj, ref_rel) = t.subject.split_once('#').unwrap();
339                self.expand_internal(ref_obj, ref_rel, depth + 1, result, visited)
340                    .await?;
341            } else if !result.contains(&t.subject) {
342                result.push(t.subject.clone());
343            }
344        }
345
346        // Union rewrites
347        if let Some((ns, _)) = object.split_once(':') {
348            let namespaces = self.namespaces.read().await;
349            if let Some(ns_config) = namespaces.get(ns) {
350                if let Some(rel_def) = ns_config.relations.get(relation) {
351                    for union_rel in &rel_def.union {
352                        self.expand_internal(object, union_rel, depth + 1, result, visited)
353                            .await?;
354                    }
355                    for ttu in &rel_def.tuple_to_userset {
356                        let parent_tuples = self
357                            .read_tuples(object, Some(&ttu.tupleset_relation))
358                            .await;
359                        for pt in &parent_tuples {
360                            self.expand_internal(
361                                &pt.subject,
362                                &ttu.computed_userset_relation,
363                                depth + 1,
364                                result,
365                                visited,
366                            )
367                            .await?;
368                        }
369                    }
370                }
371            }
372        }
373
374        Ok(())
375        })
376    }
377
378    /// **List objects** — find all objects of a given type where the subject
379    /// has the specified relation (reverse lookup, uses BFS).
380    pub async fn list_objects(
381        &self,
382        object_type: &str,
383        relation: &str,
384        subject: &str,
385    ) -> Result<Vec<String>> {
386        let prefix = format!("{object_type}:");
387        let tuples = self.tuples.read().await;
388
389        let mut found = Vec::new();
390        let mut queue: VecDeque<String> = VecDeque::new();
391        let mut seen = HashSet::new();
392
393        // Seed: all objects of this type
394        for key in tuples.keys() {
395            if key.starts_with(&prefix) {
396                queue.push_back(key.clone());
397            }
398        }
399
400        while let Some(obj) = queue.pop_front() {
401            if !seen.insert(obj.clone()) {
402                continue;
403            }
404            // We need to release the lock for the async check
405            drop(tuples);
406            if self.check(&obj, relation, subject).await? {
407                found.push(obj);
408            }
409            // Re-acquire for next iteration is not needed since we already collected keys
410            break; // We can't hold tuples across await, so do it differently
411        }
412
413        // Simpler approach: collect candidate objects first, then check each
414        let candidates: Vec<String> = {
415            let tuples = self.tuples.read().await;
416            tuples
417                .keys()
418                .filter(|k| k.starts_with(&prefix))
419                .cloned()
420                .collect()
421        };
422
423        let mut result = Vec::new();
424        for obj in candidates {
425            if self.check(&obj, relation, subject).await? {
426                result.push(obj);
427            }
428        }
429        Ok(result)
430    }
431
432    /// Get the count of stored tuples.
433    pub async fn tuple_count(&self) -> usize {
434        self.tuples
435            .read()
436            .await
437            .values()
438            .map(|v| v.len())
439            .sum()
440    }
441}
442
443impl Default for ZanzibarStore {
444    fn default() -> Self {
445        Self::new(15)
446    }
447}
448
449#[cfg(test)]
450mod tests {
451    use super::*;
452
453    // ── Tuple parsing ───────────────────────────────────────────
454
455    #[test]
456    fn test_tuple_parse_object() {
457        let t = RelationTuple::new("document:readme", "viewer", "user:alice");
458        let (ns, id) = t.parse_object().unwrap();
459        assert_eq!(ns, "document");
460        assert_eq!(id, "readme");
461    }
462
463    #[test]
464    fn test_tuple_parse_subject_direct() {
465        let t = RelationTuple::new("document:readme", "viewer", "user:alice");
466        let (ns, id, userset) = t.parse_subject().unwrap();
467        assert_eq!(ns, "user");
468        assert_eq!(id, "alice");
469        assert_eq!(userset, None);
470    }
471
472    #[test]
473    fn test_tuple_parse_subject_userset() {
474        let t = RelationTuple::new("document:readme", "viewer", "group:eng#member");
475        let (ns, id, userset) = t.parse_subject().unwrap();
476        assert_eq!(ns, "group");
477        assert_eq!(id, "eng");
478        assert_eq!(userset, Some("member"));
479    }
480
481    #[test]
482    fn test_tuple_serialization() {
483        let t = RelationTuple::new("doc:1", "viewer", "user:alice");
484        let json = serde_json::to_string(&t).unwrap();
485        let parsed: RelationTuple = serde_json::from_str(&json).unwrap();
486        assert_eq!(parsed, t);
487    }
488
489    // ── Direct check ────────────────────────────────────────────
490
491    #[tokio::test]
492    async fn test_direct_relation_check() {
493        let store = ZanzibarStore::default();
494        store
495            .write_tuple(RelationTuple::new("document:readme", "viewer", "user:alice"))
496            .await
497            .unwrap();
498
499        assert!(store.check("document:readme", "viewer", "user:alice").await.unwrap());
500        assert!(!store.check("document:readme", "viewer", "user:bob").await.unwrap());
501        assert!(!store.check("document:readme", "editor", "user:alice").await.unwrap());
502    }
503
504    // ── Union relation ──────────────────────────────────────────
505
506    #[tokio::test]
507    async fn test_union_relation() {
508        let store = ZanzibarStore::default();
509
510        // Configure: viewer includes editor (editors can also view)
511        store
512            .add_namespace(NamespaceConfig {
513                name: "document".to_string(),
514                relations: HashMap::from([
515                    (
516                        "editor".to_string(),
517                        RelationDef {
518                            direct: true,
519                            union: vec![],
520                            tuple_to_userset: vec![],
521                        },
522                    ),
523                    (
524                        "viewer".to_string(),
525                        RelationDef {
526                            direct: true,
527                            union: vec!["editor".to_string()],
528                            tuple_to_userset: vec![],
529                        },
530                    ),
531                ]),
532            })
533            .await;
534
535        store
536            .write_tuple(RelationTuple::new("document:readme", "editor", "user:alice"))
537            .await
538            .unwrap();
539
540        // Alice is an editor, viewer includes editor via union
541        assert!(store.check("document:readme", "viewer", "user:alice").await.unwrap());
542        // Alice is also directly an editor
543        assert!(store.check("document:readme", "editor", "user:alice").await.unwrap());
544        // Bob has no relation
545        assert!(!store.check("document:readme", "viewer", "user:bob").await.unwrap());
546    }
547
548    // ── Tuple-to-userset (parent folder) ────────────────────────
549
550    #[tokio::test]
551    async fn test_tuple_to_userset() {
552        let store = ZanzibarStore::default();
553
554        // Configure: document.viewer includes folder(parent).viewer
555        store
556            .add_namespace(NamespaceConfig {
557                name: "document".to_string(),
558                relations: HashMap::from([
559                    (
560                        "parent".to_string(),
561                        RelationDef {
562                            direct: true,
563                            union: vec![],
564                            tuple_to_userset: vec![],
565                        },
566                    ),
567                    (
568                        "viewer".to_string(),
569                        RelationDef {
570                            direct: true,
571                            union: vec![],
572                            tuple_to_userset: vec![TupleToUserset {
573                                tupleset_relation: "parent".to_string(),
574                                computed_userset_relation: "viewer".to_string(),
575                            }],
576                        },
577                    ),
578                ]),
579            })
580            .await;
581
582        store
583            .add_namespace(NamespaceConfig {
584                name: "folder".to_string(),
585                relations: HashMap::from([(
586                    "viewer".to_string(),
587                    RelationDef {
588                        direct: true,
589                        union: vec![],
590                        tuple_to_userset: vec![],
591                    },
592                )]),
593            })
594            .await;
595
596        // folder:docs has viewer alice
597        store
598            .write_tuple(RelationTuple::new("folder:docs", "viewer", "user:alice"))
599            .await
600            .unwrap();
601
602        // document:readme has parent folder:docs
603        store
604            .write_tuple(RelationTuple::new("document:readme", "parent", "folder:docs"))
605            .await
606            .unwrap();
607
608        // Alice should be able to view document:readme via folder:docs
609        assert!(store.check("document:readme", "viewer", "user:alice").await.unwrap());
610        // Bob cannot
611        assert!(!store.check("document:readme", "viewer", "user:bob").await.unwrap());
612    }
613
614    // ── Group membership (userset reference) ────────────────────
615
616    #[tokio::test]
617    async fn test_group_membership_userset() {
618        let store = ZanzibarStore::default();
619
620        store
621            .add_namespace(NamespaceConfig {
622                name: "group".to_string(),
623                relations: HashMap::from([(
624                    "member".to_string(),
625                    RelationDef {
626                        direct: true,
627                        union: vec![],
628                        tuple_to_userset: vec![],
629                    },
630                )]),
631            })
632            .await;
633
634        store
635            .add_namespace(NamespaceConfig {
636                name: "document".to_string(),
637                relations: HashMap::from([(
638                    "viewer".to_string(),
639                    RelationDef {
640                        direct: true,
641                        union: vec![],
642                        tuple_to_userset: vec![],
643                    },
644                )]),
645            })
646            .await;
647
648        // Alice is a member of group:eng
649        store
650            .write_tuple(RelationTuple::new("group:eng", "member", "user:alice"))
651            .await
652            .unwrap();
653
654        // group:eng#member can view document:readme
655        store
656            .write_tuple(RelationTuple::new(
657                "document:readme",
658                "viewer",
659                "group:eng#member",
660            ))
661            .await
662            .unwrap();
663
664        // Alice can view via group membership
665        assert!(store.check("document:readme", "viewer", "user:alice").await.unwrap());
666        // Bob cannot
667        assert!(!store.check("document:readme", "viewer", "user:bob").await.unwrap());
668    }
669
670    // ── Expand ──────────────────────────────────────────────────
671
672    #[tokio::test]
673    async fn test_expand() {
674        let store = ZanzibarStore::default();
675
676        store
677            .write_tuple(RelationTuple::new("document:readme", "viewer", "user:alice"))
678            .await
679            .unwrap();
680        store
681            .write_tuple(RelationTuple::new("document:readme", "viewer", "user:bob"))
682            .await
683            .unwrap();
684        store
685            .write_tuple(RelationTuple::new("document:readme", "editor", "user:carol"))
686            .await
687            .unwrap();
688
689        let viewers = store.expand("document:readme", "viewer").await.unwrap();
690        assert_eq!(viewers.len(), 2);
691        assert!(viewers.contains(&"user:alice".to_string()));
692        assert!(viewers.contains(&"user:bob".to_string()));
693    }
694
695    // ── Delete tuple ────────────────────────────────────────────
696
697    #[tokio::test]
698    async fn test_delete_tuple() {
699        let store = ZanzibarStore::default();
700        let tuple = RelationTuple::new("document:readme", "viewer", "user:alice");
701
702        store.write_tuple(tuple.clone()).await.unwrap();
703        assert!(store.check("document:readme", "viewer", "user:alice").await.unwrap());
704
705        assert!(store.delete_tuple(&tuple).await);
706        assert!(!store.check("document:readme", "viewer", "user:alice").await.unwrap());
707    }
708
709    #[tokio::test]
710    async fn test_delete_nonexistent_tuple() {
711        let store = ZanzibarStore::default();
712        let tuple = RelationTuple::new("document:readme", "viewer", "user:alice");
713        assert!(!store.delete_tuple(&tuple).await);
714    }
715
716    // ── Tuple count ─────────────────────────────────────────────
717
718    #[tokio::test]
719    async fn test_tuple_count() {
720        let store = ZanzibarStore::default();
721        assert_eq!(store.tuple_count().await, 0);
722
723        store
724            .write_tuple(RelationTuple::new("doc:1", "viewer", "user:a"))
725            .await
726            .unwrap();
727        store
728            .write_tuple(RelationTuple::new("doc:2", "viewer", "user:b"))
729            .await
730            .unwrap();
731        assert_eq!(store.tuple_count().await, 2);
732    }
733
734    // ── Namespace validation ────────────────────────────────────
735
736    #[tokio::test]
737    async fn test_invalid_relation_rejected() {
738        let store = ZanzibarStore::default();
739        store
740            .add_namespace(NamespaceConfig {
741                name: "document".to_string(),
742                relations: HashMap::from([(
743                    "viewer".to_string(),
744                    RelationDef {
745                        direct: true,
746                        union: vec![],
747                        tuple_to_userset: vec![],
748                    },
749                )]),
750            })
751            .await;
752
753        let result = store
754            .write_tuple(RelationTuple::new("document:readme", "admin", "user:alice"))
755            .await;
756        assert!(result.is_err());
757    }
758
759    // ── Cycle protection ────────────────────────────────────────
760
761    #[tokio::test]
762    async fn test_cycle_protection() {
763        let store = ZanzibarStore::new(5);
764
765        // Create a cycle: group:a member -> group:b#member, group:b member -> group:a#member
766        store
767            .write_tuple(RelationTuple::new("group:a", "member", "group:b#member"))
768            .await
769            .unwrap();
770        store
771            .write_tuple(RelationTuple::new("group:b", "member", "group:a#member"))
772            .await
773            .unwrap();
774
775        // Should not infinite-loop; returns false (or an error if depth exceeded)
776        let result = store.check("group:a", "member", "user:alice").await;
777        // Either false or depth error — both are acceptable
778        match result {
779            Ok(v) => assert!(!v),
780            Err(_) => {} // Depth exceeded is fine
781        }
782    }
783
784    // ── Read tuples ─────────────────────────────────────────────
785
786    #[tokio::test]
787    async fn test_read_tuples_with_filter() {
788        let store = ZanzibarStore::default();
789        store
790            .write_tuple(RelationTuple::new("doc:1", "viewer", "user:a"))
791            .await
792            .unwrap();
793        store
794            .write_tuple(RelationTuple::new("doc:1", "editor", "user:b"))
795            .await
796            .unwrap();
797
798        let viewers = store.read_tuples("doc:1", Some("viewer")).await;
799        assert_eq!(viewers.len(), 1);
800        assert_eq!(viewers[0].subject, "user:a");
801
802        let all = store.read_tuples("doc:1", None).await;
803        assert_eq!(all.len(), 2);
804    }
805
806    #[tokio::test]
807    async fn test_read_tuples_empty() {
808        let store = ZanzibarStore::default();
809        let result = store.read_tuples("doc:nonexistent", None).await;
810        assert!(result.is_empty());
811    }
812}