use super::resolve::resolve;
use super::types::{
CheckResult, MAX_DEPTH, NamespaceSchema, ObjectRef, PermissionExpr, RelationDef, RelationKind,
SubjectRef,
};
use std::future::Future;
use std::pin::Pin;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Verdict {
Allowed,
Denied,
DepthExceeded,
CycleDetected,
}
impl Verdict {
fn into_result(self) -> CheckResult {
match self {
Verdict::Allowed => CheckResult::Allowed {
resolved_via: Vec::new(),
},
Verdict::Denied => CheckResult::Denied,
Verdict::DepthExceeded => CheckResult::DepthExceeded,
Verdict::CycleDetected => CheckResult::CycleDetected,
}
}
}
pub trait LeafCheck: Sync {
fn check_relation_set<'a>(
&'a self,
object: &'a ObjectRef,
relations: &'a [String],
subject: &'a SubjectRef,
) -> Pin<Box<dyn Future<Output = anyhow::Result<Verdict>> + Send + 'a>>;
fn arrow_targets<'a>(
&'a self,
object: &'a ObjectRef,
relation: &'a str,
) -> Pin<Box<dyn Future<Output = anyhow::Result<Vec<ObjectRef>>> + Send + 'a>>;
fn schema_for<'a>(
&'a self,
object_type: &'a str,
) -> Pin<Box<dyn Future<Output = anyhow::Result<Option<NamespaceSchema>>> + Send + 'a>>;
}
pub async fn evaluate<B: LeafCheck>(
backend: &B,
entry_schema: &NamespaceSchema,
object: &ObjectRef,
permission: &str,
subject: &SubjectRef,
) -> anyhow::Result<CheckResult> {
let mut frames: Vec<(String, String, String)> = Vec::new();
let v = eval_name(
backend,
entry_schema,
object,
permission,
subject,
&mut frames,
)
.await?;
Ok(v.into_result())
}
fn eval_name<'a, B: LeafCheck>(
backend: &'a B,
schema: &'a NamespaceSchema,
object: &'a ObjectRef,
name: &'a str,
subject: &'a SubjectRef,
frames: &'a mut Vec<(String, String, String)>,
) -> Pin<Box<dyn Future<Output = anyhow::Result<Verdict>> + Send + 'a>> {
Box::pin(async move {
let frame = (
object.object_type.clone(),
object.object_id.clone(),
name.to_string(),
);
if frames.contains(&frame) {
return Ok(Verdict::Denied);
}
if frames.len() >= MAX_DEPTH as usize {
return Ok(Verdict::DepthExceeded);
}
frames.push(frame);
let out = eval_name_inner(backend, schema, object, name, subject, frames).await;
frames.pop();
out
})
}
async fn eval_name_inner<B: LeafCheck>(
backend: &B,
schema: &NamespaceSchema,
object: &ObjectRef,
name: &str,
subject: &SubjectRef,
frames: &mut Vec<(String, String, String)>,
) -> anyhow::Result<Verdict> {
match schema.definitions.get(name) {
None
| Some(RelationDef {
kind: RelationKind::Direct(_),
..
}) => {
let relations = [name.to_string()];
backend
.check_relation_set(object, &relations, subject)
.await
}
Some(RelationDef {
kind: RelationKind::Permission(expr),
..
}) => {
let Some(resolved) = resolve(schema, name) else {
return Ok(Verdict::CycleDetected);
};
if !resolved.needs_post_filter {
if resolved.union_relations.is_empty() {
return Ok(Verdict::Denied);
}
let relations: Vec<String> = resolved.union_relations.into_iter().collect();
return backend
.check_relation_set(object, &relations, subject)
.await;
}
eval_expr(backend, schema, object, expr, subject, frames).await
}
}
}
fn eval_expr<'a, B: LeafCheck>(
backend: &'a B,
schema: &'a NamespaceSchema,
object: &'a ObjectRef,
expr: &'a PermissionExpr,
subject: &'a SubjectRef,
frames: &'a mut Vec<(String, String, String)>,
) -> Pin<Box<dyn Future<Output = anyhow::Result<Verdict>> + Send + 'a>> {
Box::pin(async move {
match expr {
PermissionExpr::Direct { relation } => {
eval_name(backend, schema, object, relation, subject, frames).await
}
PermissionExpr::Union { left, right } => {
let l = eval_expr(backend, schema, object, left, subject, frames).await?;
if l == Verdict::Allowed {
return Ok(Verdict::Allowed);
}
let r = eval_expr(backend, schema, object, right, subject, frames).await?;
Ok(combine_union(l, r))
}
PermissionExpr::Intersect { left, right } => {
let l = eval_expr(backend, schema, object, left, subject, frames).await?;
if l == Verdict::Denied {
return Ok(Verdict::Denied);
}
let r = eval_expr(backend, schema, object, right, subject, frames).await?;
Ok(combine_intersect(l, r))
}
PermissionExpr::Exclude { left, right } => {
let l = eval_expr(backend, schema, object, left, subject, frames).await?;
if l == Verdict::Denied {
return Ok(Verdict::Denied);
}
let r = eval_expr(backend, schema, object, right, subject, frames).await?;
Ok(combine_exclude(l, r))
}
PermissionExpr::TuplesetArrow {
tupleset,
permission,
} => eval_arrow(backend, object, tupleset, permission, subject, frames).await,
}
})
}
async fn eval_arrow<B: LeafCheck>(
backend: &B,
object: &ObjectRef,
tupleset: &str,
permission: &str,
subject: &SubjectRef,
frames: &mut Vec<(String, String, String)>,
) -> anyhow::Result<Verdict> {
let targets = backend.arrow_targets(object, tupleset).await?;
let mut acc = Verdict::Denied;
for target in &targets {
let Some(target_schema) = backend.schema_for(&target.object_type).await? else {
continue;
};
let v = eval_name(backend, &target_schema, target, permission, subject, frames).await?;
if v == Verdict::Allowed {
return Ok(Verdict::Allowed);
}
acc = combine_union(acc, v);
}
Ok(acc)
}
fn combine_union(a: Verdict, b: Verdict) -> Verdict {
match (a, b) {
(Verdict::Allowed, _) | (_, Verdict::Allowed) => Verdict::Allowed,
(Verdict::DepthExceeded, _) | (_, Verdict::DepthExceeded) => Verdict::DepthExceeded,
(Verdict::CycleDetected, _) | (_, Verdict::CycleDetected) => Verdict::CycleDetected,
_ => Verdict::Denied,
}
}
fn combine_intersect(a: Verdict, b: Verdict) -> Verdict {
match (a, b) {
(Verdict::Denied, _) | (_, Verdict::Denied) => Verdict::Denied,
(Verdict::Allowed, Verdict::Allowed) => Verdict::Allowed,
(Verdict::DepthExceeded, _) | (_, Verdict::DepthExceeded) => Verdict::DepthExceeded,
(Verdict::CycleDetected, _) | (_, Verdict::CycleDetected) => Verdict::CycleDetected,
}
}
fn combine_exclude(left: Verdict, right: Verdict) -> Verdict {
match (left, right) {
(_, Verdict::Allowed) => Verdict::Denied,
(Verdict::Allowed, Verdict::Denied) => Verdict::Allowed,
(Verdict::DepthExceeded, _) | (_, Verdict::DepthExceeded) => Verdict::DepthExceeded,
(Verdict::CycleDetected, _) | (_, Verdict::CycleDetected) => Verdict::CycleDetected,
_ => Verdict::Denied,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::zanzibar::types::{RelationDef, TypeRef};
use std::collections::{BTreeMap, BTreeSet};
use std::sync::Mutex;
struct FakeBackend {
tuples: Vec<((String, String, String), SubjectRef)>,
schemas: BTreeMap<String, NamespaceSchema>,
leaf_calls: Mutex<Vec<Vec<String>>>,
}
impl FakeBackend {
fn new(schemas: Vec<NamespaceSchema>) -> Self {
Self {
tuples: Vec::new(),
schemas: schemas.into_iter().map(|s| (s.name.clone(), s)).collect(),
leaf_calls: Mutex::new(Vec::new()),
}
}
fn tuple(mut self, ot: &str, oid: &str, rel: &str, subject: SubjectRef) -> Self {
self.tuples
.push(((ot.into(), oid.into(), rel.into()), subject));
self
}
fn direct_subjects(&self, object: &ObjectRef, relation: &str) -> Vec<SubjectRef> {
self.tuples
.iter()
.filter(|((ot, oid, rel), _)| {
ot == &object.object_type && oid == &object.object_id && rel == relation
})
.map(|(_, s)| s.clone())
.collect()
}
}
impl LeafCheck for FakeBackend {
fn check_relation_set<'a>(
&'a self,
object: &'a ObjectRef,
relations: &'a [String],
subject: &'a SubjectRef,
) -> Pin<Box<dyn Future<Output = anyhow::Result<Verdict>> + Send + 'a>> {
self.leaf_calls.lock().unwrap().push(relations.to_vec());
Box::pin(async move {
let mut seen: BTreeSet<(String, String, String)> = BTreeSet::new();
let mut frontier: Vec<(ObjectRef, String)> = relations
.iter()
.map(|r| (object.clone(), r.clone()))
.collect();
let mut depth = 0u32;
while !frontier.is_empty() {
depth += 1;
if depth > MAX_DEPTH {
return Ok(Verdict::DepthExceeded);
}
let mut next = Vec::new();
for (obj, rel) in frontier.drain(..) {
for s in self.direct_subjects(&obj, &rel) {
if s.subject_rel.is_empty() {
if s.subject_type == subject.subject_type
&& s.subject_id == subject.subject_id
{
return Ok(Verdict::Allowed);
}
} else {
let key = (
s.subject_type.clone(),
s.subject_id.clone(),
s.subject_rel.clone(),
);
if seen.insert(key) {
next.push((
ObjectRef::new(s.subject_type, s.subject_id),
s.subject_rel,
));
}
}
}
}
frontier = next;
}
Ok(Verdict::Denied)
})
}
fn arrow_targets<'a>(
&'a self,
object: &'a ObjectRef,
relation: &'a str,
) -> Pin<Box<dyn Future<Output = anyhow::Result<Vec<ObjectRef>>> + Send + 'a>> {
Box::pin(async move {
Ok(self
.direct_subjects(object, relation)
.into_iter()
.filter(|s| s.subject_rel.is_empty())
.map(|s| ObjectRef::new(s.subject_type, s.subject_id))
.collect())
})
}
fn schema_for<'a>(
&'a self,
object_type: &'a str,
) -> Pin<Box<dyn Future<Output = anyhow::Result<Option<NamespaceSchema>>> + Send + 'a>>
{
Box::pin(async move { Ok(self.schemas.get(object_type).cloned()) })
}
}
fn node_schema() -> NamespaceSchema {
NamespaceSchema::new("node")
.with_relation(
"account",
RelationDef::relation("account", vec![TypeRef::direct("user")]),
)
.with_relation(
"parent",
RelationDef::relation("parent", vec![TypeRef::direct("node")]),
)
.with_relation(
"up",
RelationDef::permission(
"up",
PermissionExpr::union(
PermissionExpr::direct("account"),
PermissionExpr::arrow("parent", "up"),
),
),
)
}
async fn check(
backend: &FakeBackend,
schema: &NamespaceSchema,
oid: &str,
perm: &str,
sid: &str,
) -> CheckResult {
let object = ObjectRef::new(&schema.name, oid);
let subject = SubjectRef::direct("user", sid);
evaluate(backend, schema, &object, perm, &subject)
.await
.unwrap()
}
#[tokio::test]
async fn arrow_resolves_hierarchy() {
let schema = node_schema();
let backend = FakeBackend::new(vec![schema.clone(), NamespaceSchema::new("user")])
.tuple("node", "a", "account", SubjectRef::direct("user", "ua"))
.tuple("node", "b", "account", SubjectRef::direct("user", "ub"))
.tuple("node", "c", "account", SubjectRef::direct("user", "uc"))
.tuple("node", "b", "parent", SubjectRef::direct("node", "a"))
.tuple("node", "c", "parent", SubjectRef::direct("node", "b"));
assert!(check(&backend, &schema, "c", "up", "ua").await.is_allowed());
assert!(check(&backend, &schema, "c", "up", "ub").await.is_allowed());
assert!(check(&backend, &schema, "c", "up", "uc").await.is_allowed());
assert_eq!(
check(&backend, &schema, "c", "up", "nobody").await,
CheckResult::Denied
);
}
#[tokio::test]
async fn intersect_requires_both() {
let schema = NamespaceSchema::new("doc")
.with_relation(
"a",
RelationDef::relation("a", vec![TypeRef::direct("user")]),
)
.with_relation(
"b",
RelationDef::relation("b", vec![TypeRef::direct("user")]),
)
.with_relation(
"p",
RelationDef::permission(
"p",
PermissionExpr::intersect(
PermissionExpr::direct("a"),
PermissionExpr::direct("b"),
),
),
);
let backend = FakeBackend::new(vec![schema.clone()])
.tuple("doc", "x", "a", SubjectRef::direct("user", "alice"))
.tuple("doc", "x", "b", SubjectRef::direct("user", "alice"))
.tuple("doc", "x", "a", SubjectRef::direct("user", "bob"));
assert!(
check(&backend, &schema, "x", "p", "alice")
.await
.is_allowed()
);
assert_eq!(
check(&backend, &schema, "x", "p", "bob").await,
CheckResult::Denied
);
}
#[tokio::test]
async fn exclude_subtracts_right() {
let schema = NamespaceSchema::new("doc")
.with_relation(
"viewer",
RelationDef::relation("viewer", vec![TypeRef::direct("user")]),
)
.with_relation(
"banned",
RelationDef::relation("banned", vec![TypeRef::direct("user")]),
)
.with_relation(
"view",
RelationDef::permission(
"view",
PermissionExpr::exclude(
PermissionExpr::direct("viewer"),
PermissionExpr::direct("banned"),
),
),
);
let backend = FakeBackend::new(vec![schema.clone()])
.tuple("doc", "x", "viewer", SubjectRef::direct("user", "alice"))
.tuple("doc", "x", "viewer", SubjectRef::direct("user", "mallory"))
.tuple("doc", "x", "banned", SubjectRef::direct("user", "mallory"));
assert!(
check(&backend, &schema, "x", "view", "alice")
.await
.is_allowed()
);
assert_eq!(
check(&backend, &schema, "x", "view", "mallory").await,
CheckResult::Denied
);
}
#[tokio::test]
async fn pure_union_uses_single_leaf_check() {
let schema = NamespaceSchema::new("doc")
.with_relation(
"owner",
RelationDef::relation("owner", vec![TypeRef::direct("user")]),
)
.with_relation(
"viewer",
RelationDef::relation("viewer", vec![TypeRef::direct("user")]),
)
.with_relation(
"view",
RelationDef::permission(
"view",
PermissionExpr::union(
PermissionExpr::direct("owner"),
PermissionExpr::direct("viewer"),
),
),
);
let backend = FakeBackend::new(vec![schema.clone()]).tuple(
"doc",
"x",
"owner",
SubjectRef::direct("user", "alice"),
);
assert!(
check(&backend, &schema, "x", "view", "alice")
.await
.is_allowed()
);
let calls = backend.leaf_calls.lock().unwrap();
assert_eq!(
calls.len(),
1,
"fast path should be a single leaf check: {calls:?}"
);
assert_eq!(
calls[0].iter().cloned().collect::<BTreeSet<_>>(),
BTreeSet::from(["owner".to_string(), "viewer".to_string()])
);
}
#[tokio::test]
async fn arrow_cycle_is_safe() {
let schema = node_schema();
let backend = FakeBackend::new(vec![schema.clone(), NamespaceSchema::new("user")])
.tuple("node", "a", "parent", SubjectRef::direct("node", "b"))
.tuple("node", "b", "parent", SubjectRef::direct("node", "a"));
assert_eq!(
check(&backend, &schema, "a", "up", "ghost").await,
CheckResult::Denied
);
}
#[tokio::test]
async fn arrow_cycle_still_resolves_real_account() {
let schema = node_schema();
let backend = FakeBackend::new(vec![schema.clone(), NamespaceSchema::new("user")])
.tuple("node", "a", "parent", SubjectRef::direct("node", "b"))
.tuple("node", "b", "parent", SubjectRef::direct("node", "a"))
.tuple("node", "a", "account", SubjectRef::direct("user", "ua"));
assert!(check(&backend, &schema, "b", "up", "ua").await.is_allowed());
}
#[tokio::test]
async fn schema_cycle_reports_cycle_detected() {
let schema = NamespaceSchema::new("doc")
.with_relation(
"p",
RelationDef::permission("p", PermissionExpr::direct("q")),
)
.with_relation(
"q",
RelationDef::permission("q", PermissionExpr::direct("p")),
);
let backend = FakeBackend::new(vec![schema.clone()]);
assert_eq!(
check(&backend, &schema, "x", "p", "alice").await,
CheckResult::CycleDetected
);
}
}