Skip to main content

assay_auth/zanzibar/
types.rs

1//! Plain-old-data types for the Zanzibar / ReBAC layer.
2//!
3//! Mirrors the Google Zanzibar paper terminology (Keto / SpiceDB users
4//! will recognise the names):
5//!
6//! - **object** — a resource being protected, identified as
7//!   `<type>:<id>` (e.g. `document:foo`, `circle:immediate`).
8//! - **subject** — who's being checked. Either a *direct* user
9//!   (`user:alice`, `subject_rel = None`) or a *userset* — every member
10//!   of some other relation (`family:foo#member`, where
11//!   `subject_rel = Some("member")`).
12//! - **tuple** — the atomic permission fact:
13//!   `object#relation @ subject`. The persistence layer stores
14//!   millions of these; the recursive-CTE walks them transitively.
15//! - **namespace schema** — the authoritative description of which
16//!   relations + permissions a given `object_type` supports, parsed
17//!   from a SpiceDB-compatible DSL by [`super::schema`].
18//!
19//! All identifiers are owned `String`s — we don't intern. Tuples are
20//! short-lived in memory; the SQL layer is where dense storage lives.
21
22use std::collections::BTreeMap;
23
24use serde::{Deserialize, Serialize};
25
26/// `<type>:<id>` reference to a protected resource (the *object* side
27/// of a relation tuple). Field name is `object_type`/`object_id` to
28/// match the column names in `auth.zanzibar_tuples` 1:1 — keeps SQL
29/// hand-rolled queries readable.
30#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
31pub struct ObjectRef {
32    pub object_type: String,
33    pub object_id: String,
34}
35
36impl ObjectRef {
37    /// Convenience constructor — `ObjectRef::new("document", "foo")`.
38    pub fn new(ty: impl Into<String>, id: impl Into<String>) -> Self {
39        Self {
40            object_type: ty.into(),
41            object_id: id.into(),
42        }
43    }
44
45    /// Parse `"<type>:<id>"`. Returns `None` if no `:` separator is
46    /// present or either side is empty — callers wrap this in a typed
47    /// error appropriate to their context (HTTP 400, parser line/col,
48    /// etc.).
49    pub fn parse(s: &str) -> Option<Self> {
50        let (ty, id) = s.split_once(':')?;
51        if ty.is_empty() || id.is_empty() {
52            return None;
53        }
54        Some(Self::new(ty, id))
55    }
56
57    /// `<type>:<id>` rendering. Round-trips with [`Self::parse`].
58    pub fn render(&self) -> String {
59        format!("{}:{}", self.object_type, self.object_id)
60    }
61}
62
63/// `<type>:<id>[#<relation>]` reference. A subject is either:
64///
65/// - a **direct** user (`subject_rel = ""`) — terminal, e.g.
66///   `user:alice`, that's the leaf the recursive CTE walks toward.
67/// - a **userset** (`subject_rel = "member"`) — every member of
68///   `<type>:<id>`'s `relation`, e.g. `family:smith#member`. The walk
69///   follows these one hop at a time.
70///
71/// We use the empty string rather than `Option<String>` so the column
72/// can stay in the primary key (PG implicitly NOT-NULLs PK members) and
73/// so SQLite/PG queries can use plain equality (`subject_rel = ?`)
74/// instead of `IS NOT DISTINCT FROM`. JSON callers may either omit the
75/// field or send `""` for direct tuples; both deserialize the same way.
76#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
77pub struct SubjectRef {
78    pub subject_type: String,
79    pub subject_id: String,
80    #[serde(default)]
81    pub subject_rel: String,
82}
83
84impl SubjectRef {
85    pub fn direct(ty: impl Into<String>, id: impl Into<String>) -> Self {
86        Self {
87            subject_type: ty.into(),
88            subject_id: id.into(),
89            subject_rel: String::new(),
90        }
91    }
92
93    pub fn userset(
94        ty: impl Into<String>,
95        id: impl Into<String>,
96        relation: impl Into<String>,
97    ) -> Self {
98        Self {
99            subject_type: ty.into(),
100            subject_id: id.into(),
101            subject_rel: relation.into(),
102        }
103    }
104
105    /// `true` for `user:alice` (direct subject); `false` for
106    /// `family:smith#member` (userset).
107    pub fn is_direct(&self) -> bool {
108        self.subject_rel.is_empty()
109    }
110
111    /// Parse `"<type>:<id>"` (direct) or `"<type>:<id>#<relation>"`
112    /// (userset). Returns `None` if the structural shape is invalid.
113    pub fn parse(s: &str) -> Option<Self> {
114        let (head, rel) = match s.split_once('#') {
115            Some((h, r)) if !r.is_empty() => (h, r.to_string()),
116            Some(_) => return None,
117            None => (s, String::new()),
118        };
119        let (ty, id) = head.split_once(':')?;
120        if ty.is_empty() || id.is_empty() {
121            return None;
122        }
123        Some(Self {
124            subject_type: ty.to_string(),
125            subject_id: id.to_string(),
126            subject_rel: rel,
127        })
128    }
129
130    /// Round-trip rendering with [`Self::parse`].
131    pub fn render(&self) -> String {
132        if self.subject_rel.is_empty() {
133            format!("{}:{}", self.subject_type, self.subject_id)
134        } else {
135            format!(
136                "{}:{}#{}",
137                self.subject_type, self.subject_id, self.subject_rel
138            )
139        }
140    }
141}
142
143/// One row of `auth.zanzibar_tuples`. Field names mirror the columns
144/// 1:1 so hand-rolled SQL stays readable. `subject_rel` is the empty
145/// string for a direct subject (e.g. `user:alice`) and the relation
146/// name for a userset subject (e.g. `family:smith#member`); see
147/// [`SubjectRef`] for the rationale.
148#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
149pub struct Tuple {
150    pub object_type: String,
151    pub object_id: String,
152    pub relation: String,
153    pub subject_type: String,
154    pub subject_id: String,
155    #[serde(default)]
156    pub subject_rel: String,
157}
158
159/// Filter for listing tuples. Every field is optional; an empty filter
160/// matches every row. `limit` defaults to 100 and is clamped to 1000.
161#[derive(Clone, Debug, Default, Serialize, Deserialize)]
162pub struct TupleFilter {
163    pub object_type: Option<String>,
164    pub object_id: Option<String>,
165    pub relation: Option<String>,
166    pub subject_type: Option<String>,
167    pub subject_id: Option<String>,
168    pub limit: Option<i64>,
169    pub offset: Option<i64>,
170}
171
172impl TupleFilter {
173    /// Effective limit: caller-supplied (clamped to 1..=1000) or 100.
174    pub fn effective_limit(&self) -> i64 {
175        self.limit.map(|n| n.clamp(1, 1000)).unwrap_or(100)
176    }
177    /// Effective offset: caller-supplied (clamped to ≥0) or 0.
178    pub fn effective_offset(&self) -> i64 {
179        self.offset.map(|n| n.max(0)).unwrap_or(0)
180    }
181}
182
183impl Tuple {
184    /// Direct grant — `user:alice` is `viewer` of `document:foo`.
185    pub fn direct(
186        object: impl Into<ObjectRef>,
187        relation: impl Into<String>,
188        subject: impl Into<SubjectRef>,
189    ) -> Self {
190        let o: ObjectRef = object.into();
191        let s: SubjectRef = subject.into();
192        Self {
193            object_type: o.object_type,
194            object_id: o.object_id,
195            relation: relation.into(),
196            subject_type: s.subject_type,
197            subject_id: s.subject_id,
198            subject_rel: s.subject_rel,
199        }
200    }
201
202    pub fn object(&self) -> ObjectRef {
203        ObjectRef::new(self.object_type.clone(), self.object_id.clone())
204    }
205
206    pub fn subject(&self) -> SubjectRef {
207        SubjectRef {
208            subject_type: self.subject_type.clone(),
209            subject_id: self.subject_id.clone(),
210            subject_rel: self.subject_rel.clone(),
211        }
212    }
213}
214
215/// Read-consistency mode for `check`-style queries. Closely matches
216/// the Zanzibar paper terminology and the SpiceDB API surface.
217///
218/// - [`Consistency::Minimum`] — read at any committed snapshot. Fastest,
219///   no staleness bound. Default for non-critical UI checks.
220/// - [`Consistency::AtLeastAsFresh`] — read at a snapshot at least as
221///   recent as the provided zookie. Used right after a write to read
222///   one's own writes.
223/// - [`Consistency::Exact`] — read at exactly this snapshot. Used for
224///   cache-friendly batched checks where every check should see the
225///   same world.
226///
227/// In v0.2.0 zookies are opaque transaction-id strings; the Postgres
228/// backend serialises `pg_current_wal_lsn()` and the SQLite backend
229/// uses a monotonic counter. The current check implementation is
230/// `Consistency::Minimum` only (the other modes pass through to the
231/// same code path); full snapshot enforcement is future work.
232#[derive(Clone, Debug, PartialEq, Eq, Default)]
233pub enum Consistency {
234    #[default]
235    Minimum,
236    AtLeastAsFresh(String),
237    Exact(String),
238}
239
240/// Result of a `check` call. `Allowed` carries the (best-effort) tuple
241/// path that resolved the permission so callers can show "why?" in a
242/// debug UI; the path may be empty if the storage layer chose to skip
243/// it for performance.
244///
245/// `DepthExceeded` and `CycleDetected` are *not* errors in the
246/// `Result` sense — they're a deliberate denial signal. A buggy schema
247/// shouldn't crash the request; it should deny the access and let the
248/// operator inspect the response.
249#[derive(Clone, Debug, PartialEq, Eq)]
250pub enum CheckResult {
251    Allowed { resolved_via: Vec<Tuple> },
252    Denied,
253    DepthExceeded,
254    CycleDetected,
255}
256
257impl CheckResult {
258    /// `true` iff [`CheckResult::Allowed`] — convenient for `if check.is_allowed()`.
259    pub fn is_allowed(&self) -> bool {
260        matches!(self, CheckResult::Allowed { .. })
261    }
262}
263
264/// Tree returned by [`super::ZanzibarStore::expand`]. Models the
265/// Zanzibar paper's "userset rewrite tree":
266///
267/// - [`UsersetTree::Leaf`] — terminal, a concrete user (or any
268///   no-relation subject).
269/// - [`UsersetTree::Node`] — an interior node showing how the
270///   permission was decomposed (union/intersect/exclude) plus the
271///   resolved children.
272///
273/// Mostly diagnostic — used by admin tooling and tests. The hot
274/// `check` path doesn't materialise a full tree.
275#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
276#[serde(tag = "kind", rename_all = "snake_case")]
277pub enum UsersetTree {
278    Leaf {
279        subject: SubjectRef,
280    },
281    Node {
282        op: TreeOp,
283        children: Vec<UsersetTree>,
284    },
285}
286
287/// How a non-leaf [`UsersetTree`] node combines its children.
288#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
289#[serde(rename_all = "snake_case")]
290pub enum TreeOp {
291    Union,
292    Intersect,
293    Exclude,
294    /// `viewer` resolved by following the named relation tuples
295    /// directly — the most common shape, e.g. `permission view = viewer`.
296    Direct,
297    /// Userset rewrite via `relation->permission` arrow.
298    TuplesetArrow,
299}
300
301/// Persisted namespace definition — written by `define_namespace`,
302/// read back by every `check` to resolve a permission name to its
303/// underlying relation set.
304///
305/// Kept simple on purpose: the parsed [`super::schema`] AST round-
306/// trips through `serde_json` into `auth.zanzibar_namespaces.schema_json`,
307/// so adding a new permission shape later only needs a parser change,
308/// not a storage migration.
309#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
310pub struct NamespaceSchema {
311    pub name: String,
312    /// Ordered map keyed by relation/permission name. `BTreeMap` keeps
313    /// JSON serialisation stable across runs (matters for diff-friendly
314    /// `auth.zanzibar_namespaces.schema_json` history).
315    pub definitions: BTreeMap<String, RelationDef>,
316}
317
318impl NamespaceSchema {
319    pub fn new(name: impl Into<String>) -> Self {
320        Self {
321            name: name.into(),
322            definitions: BTreeMap::new(),
323        }
324    }
325
326    pub fn with_relation(mut self, name: impl Into<String>, def: RelationDef) -> Self {
327        self.definitions.insert(name.into(), def);
328        self
329    }
330}
331
332/// A single line in a SpiceDB schema — `relation owner: user`,
333/// `permission view = owner + viewer`, etc. Holds either the parsed
334/// type list (for `relation` lines) or the algebraic expression (for
335/// `permission` lines).
336#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
337pub struct RelationDef {
338    pub name: String,
339    pub kind: RelationKind,
340}
341
342impl RelationDef {
343    pub fn relation(name: impl Into<String>, types: Vec<TypeRef>) -> Self {
344        Self {
345            name: name.into(),
346            kind: RelationKind::Direct(types),
347        }
348    }
349
350    pub fn permission(name: impl Into<String>, expr: PermissionExpr) -> Self {
351        Self {
352            name: name.into(),
353            kind: RelationKind::Permission(Box::new(expr)),
354        }
355    }
356}
357
358/// Categorises a definition line.
359///
360/// - [`RelationKind::Direct`] — `relation NAME: TYPE_LIST`. Only direct
361///   tuples count (no rewrite expansion).
362/// - [`RelationKind::Permission`] — `permission NAME = EXPR`. The
363///   expression is composed of unions / intersects / exclusions /
364///   tupleset arrows over relation names defined elsewhere in the
365///   namespace.
366#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
367#[serde(tag = "kind", content = "value", rename_all = "snake_case")]
368pub enum RelationKind {
369    Direct(Vec<TypeRef>),
370    Permission(Box<PermissionExpr>),
371}
372
373/// A type reference on the right-hand side of a `relation` line.
374/// `user` is `TypeRef::direct("user")`; `family#member` is
375/// `TypeRef::userset("family", "member")`; `user:*` is the wildcard form.
376#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
377pub struct TypeRef {
378    pub object_type: String,
379    /// Userset reference — `family#member`. `None` = a direct subject.
380    #[serde(default)]
381    pub relation: Option<String>,
382    /// Wildcard subject id — `user:*`. When `true` the parser saw
383    /// `user:*` (any user is allowed) instead of just `user`. Treated
384    /// as a permission shape rather than a sentinel value at the SQL
385    /// layer. Defaults to `false` when the field is omitted by Lua /
386    /// JSON callers (the common case — wildcards are an escape hatch).
387    #[serde(default)]
388    pub wildcard: bool,
389}
390
391impl TypeRef {
392    pub fn direct(ty: impl Into<String>) -> Self {
393        Self {
394            object_type: ty.into(),
395            relation: None,
396            wildcard: false,
397        }
398    }
399
400    pub fn userset(ty: impl Into<String>, relation: impl Into<String>) -> Self {
401        Self {
402            object_type: ty.into(),
403            relation: Some(relation.into()),
404            wildcard: false,
405        }
406    }
407
408    pub fn wildcard(ty: impl Into<String>) -> Self {
409        Self {
410            object_type: ty.into(),
411            relation: None,
412            wildcard: true,
413        }
414    }
415}
416
417/// Algebraic permission expression — the right-hand side of a
418/// `permission NAME = EXPR` line.
419///
420/// Composes via:
421///
422/// - [`PermissionExpr::Direct`] — name of a relation/permission to
423///   resolve directly. The base case.
424/// - [`PermissionExpr::Union`] / `Intersect` / `Exclude` — set ops
425///   over two child expressions. Parsed as left-associative.
426/// - [`PermissionExpr::TuplesetArrow`] — `relation->permission` — for
427///   each tuple `(object, relation, intermediate_subject)`, recurse
428///   into `intermediate_subject` checking `permission`.
429#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
430#[serde(tag = "op", rename_all = "snake_case")]
431pub enum PermissionExpr {
432    Direct {
433        relation: String,
434    },
435    Union {
436        left: Box<PermissionExpr>,
437        right: Box<PermissionExpr>,
438    },
439    Intersect {
440        left: Box<PermissionExpr>,
441        right: Box<PermissionExpr>,
442    },
443    Exclude {
444        left: Box<PermissionExpr>,
445        right: Box<PermissionExpr>,
446    },
447    TuplesetArrow {
448        tupleset: String,
449        permission: String,
450    },
451}
452
453impl PermissionExpr {
454    pub fn direct(relation: impl Into<String>) -> Self {
455        Self::Direct {
456            relation: relation.into(),
457        }
458    }
459
460    pub fn union(l: PermissionExpr, r: PermissionExpr) -> Self {
461        Self::Union {
462            left: Box::new(l),
463            right: Box::new(r),
464        }
465    }
466
467    pub fn intersect(l: PermissionExpr, r: PermissionExpr) -> Self {
468        Self::Intersect {
469            left: Box::new(l),
470            right: Box::new(r),
471        }
472    }
473
474    pub fn exclude(l: PermissionExpr, r: PermissionExpr) -> Self {
475        Self::Exclude {
476            left: Box::new(l),
477            right: Box::new(r),
478        }
479    }
480
481    pub fn arrow(tupleset: impl Into<String>, permission: impl Into<String>) -> Self {
482        Self::TuplesetArrow {
483            tupleset: tupleset.into(),
484            permission: permission.into(),
485        }
486    }
487}
488
489/// Maximum recursion depth for `check` / `expand` walks. Matches plan
490/// 11's choice and the SpiceDB default. A real-world Zanzibar
491/// deployment rarely exceeds depth ~10; 50 leaves headroom for
492/// pathological-but-legitimate schemas (deeply nested groups).
493pub const MAX_DEPTH: u32 = 50;
494
495#[cfg(test)]
496mod tests {
497    use super::*;
498
499    #[test]
500    fn object_round_trips() {
501        let o = ObjectRef::new("document", "foo");
502        assert_eq!(o.render(), "document:foo");
503        assert_eq!(ObjectRef::parse("document:foo"), Some(o));
504        assert_eq!(ObjectRef::parse(""), None);
505        assert_eq!(ObjectRef::parse("nope"), None);
506        assert_eq!(ObjectRef::parse("a:"), None);
507    }
508
509    #[test]
510    fn subject_round_trips() {
511        let direct = SubjectRef::direct("user", "alice");
512        assert_eq!(direct.render(), "user:alice");
513        assert_eq!(SubjectRef::parse("user:alice"), Some(direct));
514
515        let userset = SubjectRef::userset("family", "ahmed", "member");
516        assert_eq!(userset.render(), "family:ahmed#member");
517        assert_eq!(SubjectRef::parse("family:ahmed#member"), Some(userset));
518
519        // Reject empty parts.
520        assert_eq!(SubjectRef::parse(""), None);
521        assert_eq!(SubjectRef::parse("user:#member"), None);
522        assert_eq!(SubjectRef::parse("family:ahmed#"), None);
523    }
524
525    #[test]
526    fn check_result_is_allowed() {
527        assert!(
528            CheckResult::Allowed {
529                resolved_via: vec![]
530            }
531            .is_allowed()
532        );
533        assert!(!CheckResult::Denied.is_allowed());
534        assert!(!CheckResult::DepthExceeded.is_allowed());
535        assert!(!CheckResult::CycleDetected.is_allowed());
536    }
537}