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
159impl Tuple {
160 /// Direct grant — `user:alice` is `viewer` of `document:foo`.
161 pub fn direct(
162 object: impl Into<ObjectRef>,
163 relation: impl Into<String>,
164 subject: impl Into<SubjectRef>,
165 ) -> Self {
166 let o: ObjectRef = object.into();
167 let s: SubjectRef = subject.into();
168 Self {
169 object_type: o.object_type,
170 object_id: o.object_id,
171 relation: relation.into(),
172 subject_type: s.subject_type,
173 subject_id: s.subject_id,
174 subject_rel: s.subject_rel,
175 }
176 }
177
178 pub fn object(&self) -> ObjectRef {
179 ObjectRef::new(self.object_type.clone(), self.object_id.clone())
180 }
181
182 pub fn subject(&self) -> SubjectRef {
183 SubjectRef {
184 subject_type: self.subject_type.clone(),
185 subject_id: self.subject_id.clone(),
186 subject_rel: self.subject_rel.clone(),
187 }
188 }
189}
190
191/// Read-consistency mode for `check`-style queries. Closely matches
192/// the Zanzibar paper terminology and the SpiceDB API surface.
193///
194/// - [`Consistency::Minimum`] — read at any committed snapshot. Fastest,
195/// no staleness bound. Default for non-critical UI checks.
196/// - [`Consistency::AtLeastAsFresh`] — read at a snapshot at least as
197/// recent as the provided zookie. Used right after a write to read
198/// one's own writes.
199/// - [`Consistency::Exact`] — read at exactly this snapshot. Used for
200/// cache-friendly batched checks where every check should see the
201/// same world.
202///
203/// In v0.2.0 zookies are opaque transaction-id strings; the Postgres
204/// backend serialises `pg_current_wal_lsn()` and the SQLite backend
205/// uses a monotonic counter. The current check implementation is
206/// `Consistency::Minimum` only (the other modes pass through to the
207/// same code path); full snapshot enforcement is future work.
208#[derive(Clone, Debug, PartialEq, Eq, Default)]
209pub enum Consistency {
210 #[default]
211 Minimum,
212 AtLeastAsFresh(String),
213 Exact(String),
214}
215
216/// Result of a `check` call. `Allowed` carries the (best-effort) tuple
217/// path that resolved the permission so callers can show "why?" in a
218/// debug UI; the path may be empty if the storage layer chose to skip
219/// it for performance.
220///
221/// `DepthExceeded` and `CycleDetected` are *not* errors in the
222/// `Result` sense — they're a deliberate denial signal. A buggy schema
223/// shouldn't crash the request; it should deny the access and let the
224/// operator inspect the response.
225#[derive(Clone, Debug, PartialEq, Eq)]
226pub enum CheckResult {
227 Allowed { resolved_via: Vec<Tuple> },
228 Denied,
229 DepthExceeded,
230 CycleDetected,
231}
232
233impl CheckResult {
234 /// `true` iff [`CheckResult::Allowed`] — convenient for `if check.is_allowed()`.
235 pub fn is_allowed(&self) -> bool {
236 matches!(self, CheckResult::Allowed { .. })
237 }
238}
239
240/// Tree returned by [`super::ZanzibarStore::expand`]. Models the
241/// Zanzibar paper's "userset rewrite tree":
242///
243/// - [`UsersetTree::Leaf`] — terminal, a concrete user (or any
244/// no-relation subject).
245/// - [`UsersetTree::Node`] — an interior node showing how the
246/// permission was decomposed (union/intersect/exclude) plus the
247/// resolved children.
248///
249/// Mostly diagnostic — used by admin tooling and tests. The hot
250/// `check` path doesn't materialise a full tree.
251#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
252#[serde(tag = "kind", rename_all = "snake_case")]
253pub enum UsersetTree {
254 Leaf {
255 subject: SubjectRef,
256 },
257 Node {
258 op: TreeOp,
259 children: Vec<UsersetTree>,
260 },
261}
262
263/// How a non-leaf [`UsersetTree`] node combines its children.
264#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
265#[serde(rename_all = "snake_case")]
266pub enum TreeOp {
267 Union,
268 Intersect,
269 Exclude,
270 /// `viewer` resolved by following the named relation tuples
271 /// directly — the most common shape, e.g. `permission view = viewer`.
272 Direct,
273 /// Userset rewrite via `relation->permission` arrow.
274 TuplesetArrow,
275}
276
277/// Persisted namespace definition — written by `define_namespace`,
278/// read back by every `check` to resolve a permission name to its
279/// underlying relation set.
280///
281/// Kept simple on purpose: the parsed [`super::schema`] AST round-
282/// trips through `serde_json` into `auth.zanzibar_namespaces.schema_json`,
283/// so adding a new permission shape later only needs a parser change,
284/// not a storage migration.
285#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
286pub struct NamespaceSchema {
287 pub name: String,
288 /// Ordered map keyed by relation/permission name. `BTreeMap` keeps
289 /// JSON serialisation stable across runs (matters for diff-friendly
290 /// `auth.zanzibar_namespaces.schema_json` history).
291 pub definitions: BTreeMap<String, RelationDef>,
292}
293
294impl NamespaceSchema {
295 pub fn new(name: impl Into<String>) -> Self {
296 Self {
297 name: name.into(),
298 definitions: BTreeMap::new(),
299 }
300 }
301
302 pub fn with_relation(mut self, name: impl Into<String>, def: RelationDef) -> Self {
303 self.definitions.insert(name.into(), def);
304 self
305 }
306}
307
308/// A single line in a SpiceDB schema — `relation owner: user`,
309/// `permission view = owner + viewer`, etc. Holds either the parsed
310/// type list (for `relation` lines) or the algebraic expression (for
311/// `permission` lines).
312#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
313pub struct RelationDef {
314 pub name: String,
315 pub kind: RelationKind,
316}
317
318impl RelationDef {
319 pub fn relation(name: impl Into<String>, types: Vec<TypeRef>) -> Self {
320 Self {
321 name: name.into(),
322 kind: RelationKind::Direct(types),
323 }
324 }
325
326 pub fn permission(name: impl Into<String>, expr: PermissionExpr) -> Self {
327 Self {
328 name: name.into(),
329 kind: RelationKind::Permission(Box::new(expr)),
330 }
331 }
332}
333
334/// Categorises a definition line.
335///
336/// - [`RelationKind::Direct`] — `relation NAME: TYPE_LIST`. Only direct
337/// tuples count (no rewrite expansion).
338/// - [`RelationKind::Permission`] — `permission NAME = EXPR`. The
339/// expression is composed of unions / intersects / exclusions /
340/// tupleset arrows over relation names defined elsewhere in the
341/// namespace.
342#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
343#[serde(tag = "kind", content = "value", rename_all = "snake_case")]
344pub enum RelationKind {
345 Direct(Vec<TypeRef>),
346 Permission(Box<PermissionExpr>),
347}
348
349/// A type reference on the right-hand side of a `relation` line.
350/// `user` is `TypeRef::direct("user")`; `family#member` is
351/// `TypeRef::userset("family", "member")`; `user:*` is the wildcard form.
352#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
353pub struct TypeRef {
354 pub object_type: String,
355 /// Userset reference — `family#member`. `None` = a direct subject.
356 #[serde(default)]
357 pub relation: Option<String>,
358 /// Wildcard subject id — `user:*`. When `true` the parser saw
359 /// `user:*` (any user is allowed) instead of just `user`. Treated
360 /// as a permission shape rather than a sentinel value at the SQL
361 /// layer. Defaults to `false` when the field is omitted by Lua /
362 /// JSON callers (the common case — wildcards are an escape hatch).
363 #[serde(default)]
364 pub wildcard: bool,
365}
366
367impl TypeRef {
368 pub fn direct(ty: impl Into<String>) -> Self {
369 Self {
370 object_type: ty.into(),
371 relation: None,
372 wildcard: false,
373 }
374 }
375
376 pub fn userset(ty: impl Into<String>, relation: impl Into<String>) -> Self {
377 Self {
378 object_type: ty.into(),
379 relation: Some(relation.into()),
380 wildcard: false,
381 }
382 }
383
384 pub fn wildcard(ty: impl Into<String>) -> Self {
385 Self {
386 object_type: ty.into(),
387 relation: None,
388 wildcard: true,
389 }
390 }
391}
392
393/// Algebraic permission expression — the right-hand side of a
394/// `permission NAME = EXPR` line.
395///
396/// Composes via:
397///
398/// - [`PermissionExpr::Direct`] — name of a relation/permission to
399/// resolve directly. The base case.
400/// - [`PermissionExpr::Union`] / `Intersect` / `Exclude` — set ops
401/// over two child expressions. Parsed as left-associative.
402/// - [`PermissionExpr::TuplesetArrow`] — `relation->permission` — for
403/// each tuple `(object, relation, intermediate_subject)`, recurse
404/// into `intermediate_subject` checking `permission`.
405#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
406#[serde(tag = "op", rename_all = "snake_case")]
407pub enum PermissionExpr {
408 Direct {
409 relation: String,
410 },
411 Union {
412 left: Box<PermissionExpr>,
413 right: Box<PermissionExpr>,
414 },
415 Intersect {
416 left: Box<PermissionExpr>,
417 right: Box<PermissionExpr>,
418 },
419 Exclude {
420 left: Box<PermissionExpr>,
421 right: Box<PermissionExpr>,
422 },
423 TuplesetArrow {
424 tupleset: String,
425 permission: String,
426 },
427}
428
429impl PermissionExpr {
430 pub fn direct(relation: impl Into<String>) -> Self {
431 Self::Direct {
432 relation: relation.into(),
433 }
434 }
435
436 pub fn union(l: PermissionExpr, r: PermissionExpr) -> Self {
437 Self::Union {
438 left: Box::new(l),
439 right: Box::new(r),
440 }
441 }
442
443 pub fn intersect(l: PermissionExpr, r: PermissionExpr) -> Self {
444 Self::Intersect {
445 left: Box::new(l),
446 right: Box::new(r),
447 }
448 }
449
450 pub fn exclude(l: PermissionExpr, r: PermissionExpr) -> Self {
451 Self::Exclude {
452 left: Box::new(l),
453 right: Box::new(r),
454 }
455 }
456
457 pub fn arrow(tupleset: impl Into<String>, permission: impl Into<String>) -> Self {
458 Self::TuplesetArrow {
459 tupleset: tupleset.into(),
460 permission: permission.into(),
461 }
462 }
463}
464
465/// Maximum recursion depth for `check` / `expand` walks. Matches plan
466/// 11's choice and the SpiceDB default. A real-world Zanzibar
467/// deployment rarely exceeds depth ~10; 50 leaves headroom for
468/// pathological-but-legitimate schemas (deeply nested groups).
469pub const MAX_DEPTH: u32 = 50;
470
471#[cfg(test)]
472mod tests {
473 use super::*;
474
475 #[test]
476 fn object_round_trips() {
477 let o = ObjectRef::new("document", "foo");
478 assert_eq!(o.render(), "document:foo");
479 assert_eq!(ObjectRef::parse("document:foo"), Some(o));
480 assert_eq!(ObjectRef::parse(""), None);
481 assert_eq!(ObjectRef::parse("nope"), None);
482 assert_eq!(ObjectRef::parse("a:"), None);
483 }
484
485 #[test]
486 fn subject_round_trips() {
487 let direct = SubjectRef::direct("user", "alice");
488 assert_eq!(direct.render(), "user:alice");
489 assert_eq!(SubjectRef::parse("user:alice"), Some(direct));
490
491 let userset = SubjectRef::userset("family", "ahmed", "member");
492 assert_eq!(userset.render(), "family:ahmed#member");
493 assert_eq!(SubjectRef::parse("family:ahmed#member"), Some(userset));
494
495 // Reject empty parts.
496 assert_eq!(SubjectRef::parse(""), None);
497 assert_eq!(SubjectRef::parse("user:#member"), None);
498 assert_eq!(SubjectRef::parse("family:ahmed#"), None);
499 }
500
501 #[test]
502 fn check_result_is_allowed() {
503 assert!(
504 CheckResult::Allowed {
505 resolved_via: vec![]
506 }
507 .is_allowed()
508 );
509 assert!(!CheckResult::Denied.is_allowed());
510 assert!(!CheckResult::DepthExceeded.is_allowed());
511 assert!(!CheckResult::CycleDetected.is_allowed());
512 }
513}