use std::collections::BTreeSet;
use super::types::{NamespaceSchema, PermissionExpr, RelationKind};
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Resolved {
pub union_relations: BTreeSet<String>,
pub needs_post_filter: bool,
}
impl Resolved {
fn new() -> Self {
Self {
union_relations: BTreeSet::new(),
needs_post_filter: false,
}
}
}
pub fn resolve(schema: &NamespaceSchema, permission_or_relation: &str) -> Option<Resolved> {
let mut out = Resolved::new();
let mut seen = BTreeSet::new();
walk_name(schema, permission_or_relation, &mut out, &mut seen).then_some(out)
}
fn walk_name(
schema: &NamespaceSchema,
name: &str,
out: &mut Resolved,
seen: &mut BTreeSet<String>,
) -> bool {
if !seen.insert(name.to_string()) {
return false;
}
let ok = match schema.definitions.get(name) {
None => {
out.union_relations.insert(name.to_string());
true
}
Some(def) => match &def.kind {
RelationKind::Direct(_) => {
out.union_relations.insert(name.to_string());
true
}
RelationKind::Permission(expr) => walk_expr(schema, expr, out, seen),
},
};
seen.remove(name);
ok
}
fn walk_expr(
schema: &NamespaceSchema,
expr: &PermissionExpr,
out: &mut Resolved,
seen: &mut BTreeSet<String>,
) -> bool {
match expr {
PermissionExpr::Direct { relation } => walk_name(schema, relation, out, seen),
PermissionExpr::Union { left, right } => {
walk_expr(schema, left, out, seen) && walk_expr(schema, right, out, seen)
}
PermissionExpr::Intersect { left, right } => {
out.needs_post_filter = true;
walk_expr(schema, left, out, seen) && walk_expr(schema, right, out, seen)
}
PermissionExpr::Exclude { left, right } => {
out.needs_post_filter = true;
walk_expr(schema, left, out, seen) && walk_expr(schema, right, out, seen)
}
PermissionExpr::TuplesetArrow {
tupleset,
permission: _,
} => {
out.needs_post_filter = true;
out.union_relations.insert(tupleset.to_string());
true
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::zanzibar::types::{NamespaceSchema, PermissionExpr, RelationDef, TypeRef};
fn doc_schema() -> NamespaceSchema {
NamespaceSchema::new("document")
.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"),
),
),
)
.with_relation(
"edit",
RelationDef::permission("edit", PermissionExpr::direct("owner")),
)
}
#[test]
fn resolves_union_permission() {
let r = resolve(&doc_schema(), "view").expect("resolve");
assert_eq!(
r.union_relations,
BTreeSet::from(["owner".into(), "viewer".into()])
);
assert!(!r.needs_post_filter);
}
#[test]
fn resolves_direct_permission() {
let r = resolve(&doc_schema(), "edit").expect("resolve");
assert_eq!(r.union_relations, BTreeSet::from(["owner".into()]));
}
#[test]
fn resolves_relation_as_self() {
let r = resolve(&doc_schema(), "owner").expect("resolve");
assert_eq!(r.union_relations, BTreeSet::from(["owner".into()]));
}
#[test]
fn cycle_detected() {
let mut s = NamespaceSchema::new("x");
s.definitions.insert(
"a".into(),
RelationDef::permission("a", PermissionExpr::direct("b")),
);
s.definitions.insert(
"b".into(),
RelationDef::permission("b", PermissionExpr::direct("a")),
);
assert!(resolve(&s, "a").is_none());
}
#[test]
fn unknown_name_treated_as_direct() {
let r = resolve(&doc_schema(), "ghost").expect("resolve");
assert_eq!(r.union_relations, BTreeSet::from(["ghost".into()]));
}
#[test]
fn intersect_marks_post_filter() {
let mut s = NamespaceSchema::new("x");
s.definitions.insert(
"a".into(),
RelationDef::relation("a", vec![TypeRef::direct("user")]),
);
s.definitions.insert(
"b".into(),
RelationDef::relation("b", vec![TypeRef::direct("user")]),
);
s.definitions.insert(
"p".into(),
RelationDef::permission(
"p",
PermissionExpr::intersect(PermissionExpr::direct("a"), PermissionExpr::direct("b")),
),
);
let r = resolve(&s, "p").expect("resolve");
assert!(r.needs_post_filter);
assert!(r.union_relations.contains("a"));
assert!(r.union_relations.contains("b"));
}
#[test]
fn diamond_via_two_permissions_is_not_a_cycle() {
let mut s = NamespaceSchema::new("node");
s.definitions.insert(
"account".into(),
RelationDef::relation("account", vec![TypeRef::direct("user")]),
);
s.definitions.insert(
"parent".into(),
RelationDef::relation("parent", vec![TypeRef::direct("node")]),
);
s.definitions.insert(
"child".into(),
RelationDef::relation("child", vec![TypeRef::direct("node")]),
);
s.definitions.insert(
"up".into(),
RelationDef::permission(
"up",
PermissionExpr::union(
PermissionExpr::direct("account"),
PermissionExpr::arrow("parent", "up"),
),
),
);
s.definitions.insert(
"down".into(),
RelationDef::permission(
"down",
PermissionExpr::union(
PermissionExpr::direct("account"),
PermissionExpr::arrow("child", "down"),
),
),
);
s.definitions.insert(
"view".into(),
RelationDef::permission(
"view",
PermissionExpr::union(PermissionExpr::direct("up"), PermissionExpr::direct("down")),
),
);
let r = resolve(&s, "view").expect("diamond must resolve, not register as a cycle");
assert!(r.union_relations.contains("account"));
assert!(r.union_relations.contains("parent"));
assert!(r.union_relations.contains("child"));
assert!(r.needs_post_filter, "arrows present → post-filter");
}
}