use std::sync::Arc;
use anyhow::{Context, Result};
use sqlx::{PgPool, Row};
use super::eval::{LeafCheck, Verdict, evaluate};
use super::resolve::resolve;
use super::store::ZanzibarStore;
use super::types::{
CheckResult, Consistency, MAX_DEPTH, NamespaceSchema, ObjectRef, SubjectRef, TreeOp, Tuple,
TupleFilter, UsersetTree,
};
use std::future::Future;
use std::pin::Pin;
#[derive(Clone)]
pub struct PostgresZanzibarStore {
pool: PgPool,
}
impl PostgresZanzibarStore {
pub fn new(pool: PgPool) -> Self {
Self { pool }
}
pub fn into_dyn(self) -> Arc<dyn ZanzibarStore> {
Arc::new(self)
}
}
#[async_trait::async_trait]
impl ZanzibarStore for PostgresZanzibarStore {
async fn define_namespace(&self, schema: &NamespaceSchema) -> Result<()> {
let json = serde_json::to_value(schema).context("zanzibar serialize NamespaceSchema")?;
sqlx::query(
"INSERT INTO auth.zanzibar_namespaces (name, schema_json, updated_at)
VALUES ($1, $2, EXTRACT(EPOCH FROM NOW()))
ON CONFLICT (name) DO UPDATE
SET schema_json = EXCLUDED.schema_json,
updated_at = EXCLUDED.updated_at",
)
.bind(&schema.name)
.bind(json)
.execute(&self.pool)
.await
.context("auth.zanzibar_namespaces upsert")?;
Ok(())
}
async fn get_namespace(&self, name: &str) -> Result<Option<NamespaceSchema>> {
let row: Option<(serde_json::Value,)> =
sqlx::query_as("SELECT schema_json FROM auth.zanzibar_namespaces WHERE name = $1")
.bind(name)
.fetch_optional(&self.pool)
.await
.context("auth.zanzibar_namespaces get")?;
Ok(match row {
Some((json,)) => {
Some(serde_json::from_value(json).context("zanzibar deserialize NamespaceSchema")?)
}
None => None,
})
}
async fn list_namespaces(&self) -> Result<Vec<NamespaceSchema>> {
let rows: Vec<(serde_json::Value,)> =
sqlx::query_as("SELECT schema_json FROM auth.zanzibar_namespaces ORDER BY name")
.fetch_all(&self.pool)
.await
.context("auth.zanzibar_namespaces list")?;
rows.into_iter()
.map(|(json,)| {
serde_json::from_value(json).context("zanzibar deserialize NamespaceSchema")
})
.collect()
}
async fn write_tuple(&self, t: &Tuple) -> Result<()> {
sqlx::query(
"INSERT INTO auth.zanzibar_tuples
(object_type, object_id, relation,
subject_type, subject_id, subject_rel, created_at)
VALUES ($1, $2, $3, $4, $5, $6, EXTRACT(EPOCH FROM NOW()))
ON CONFLICT DO NOTHING",
)
.bind(&t.object_type)
.bind(&t.object_id)
.bind(&t.relation)
.bind(&t.subject_type)
.bind(&t.subject_id)
.bind(&t.subject_rel)
.execute(&self.pool)
.await
.context("auth.zanzibar_tuples insert")?;
Ok(())
}
async fn write_tuples(&self, tuples: &[Tuple]) -> Result<()> {
if tuples.is_empty() {
return Ok(());
}
let mut tx = self.pool.begin().await.context("begin tuples txn")?;
for t in tuples {
sqlx::query(
"INSERT INTO auth.zanzibar_tuples
(object_type, object_id, relation,
subject_type, subject_id, subject_rel, created_at)
VALUES ($1, $2, $3, $4, $5, $6, EXTRACT(EPOCH FROM NOW()))
ON CONFLICT DO NOTHING",
)
.bind(&t.object_type)
.bind(&t.object_id)
.bind(&t.relation)
.bind(&t.subject_type)
.bind(&t.subject_id)
.bind(&t.subject_rel)
.execute(&mut *tx)
.await
.context("auth.zanzibar_tuples batch insert")?;
}
tx.commit().await.context("commit tuples txn")?;
Ok(())
}
async fn delete_tuple(&self, t: &Tuple) -> Result<bool> {
let res = sqlx::query(
"DELETE FROM auth.zanzibar_tuples
WHERE object_type = $1 AND object_id = $2 AND relation = $3
AND subject_type = $4 AND subject_id = $5
AND subject_rel = $6",
)
.bind(&t.object_type)
.bind(&t.object_id)
.bind(&t.relation)
.bind(&t.subject_type)
.bind(&t.subject_id)
.bind(&t.subject_rel)
.execute(&self.pool)
.await
.context("auth.zanzibar_tuples delete")?;
Ok(res.rows_affected() > 0)
}
async fn list_tuples(&self, filter: &TupleFilter) -> Result<Vec<Tuple>> {
let rows: Vec<(String, String, String, String, String, String)> = sqlx::query_as(
"SELECT object_type, object_id, relation,
subject_type, subject_id, subject_rel
FROM auth.zanzibar_tuples
WHERE ($1::text IS NULL OR object_type = $1)
AND ($2::text IS NULL OR object_id = $2)
AND ($3::text IS NULL OR relation = $3)
AND ($4::text IS NULL OR subject_type = $4)
AND ($5::text IS NULL OR subject_id = $5)
ORDER BY object_type, object_id, relation, subject_type, subject_id
LIMIT $6 OFFSET $7",
)
.bind(&filter.object_type)
.bind(&filter.object_id)
.bind(&filter.relation)
.bind(&filter.subject_type)
.bind(&filter.subject_id)
.bind(filter.effective_limit())
.bind(filter.effective_offset())
.fetch_all(&self.pool)
.await
.context("auth.zanzibar_tuples list")?;
Ok(rows
.into_iter()
.map(|(ot, oid, rel, st, sid, srel)| Tuple {
object_type: ot,
object_id: oid,
relation: rel,
subject_type: st,
subject_id: sid,
subject_rel: srel,
})
.collect())
}
async fn check(
&self,
resource: &ObjectRef,
permission: &str,
subject: &SubjectRef,
_consistency: Consistency,
) -> Result<CheckResult> {
let Some(schema) = self.get_namespace(&resource.object_type).await? else {
return Ok(CheckResult::Denied);
};
evaluate(self, &schema, resource, permission, subject).await
}
async fn expand(
&self,
resource: &ObjectRef,
relation: &str,
depth_limit: u32,
) -> Result<UsersetTree> {
let depth = depth_limit.min(MAX_DEPTH);
Ok(UsersetTree::Node {
op: TreeOp::Direct,
children: expand_pg(self, resource, relation, depth, &mut Vec::new()).await?,
})
}
async fn lookup_resources(
&self,
resource_type: &str,
permission: &str,
subject: &SubjectRef,
) -> Result<Vec<ObjectRef>> {
let Some(schema) = self.get_namespace(resource_type).await? else {
return Ok(Vec::new());
};
let Some(resolved) = resolve(&schema, permission) else {
return Ok(Vec::new());
};
if resolved.union_relations.is_empty() {
return Ok(Vec::new());
}
let relation_list: Vec<String> = resolved.union_relations.into_iter().collect();
let rows = sqlx::query(
"SELECT DISTINCT object_type, object_id
FROM auth.zanzibar_tuples
WHERE object_type = $1 AND relation = ANY($2)",
)
.bind(resource_type)
.bind(&relation_list)
.fetch_all(&self.pool)
.await
.context("auth.zanzibar_tuples candidate resources")?;
let mut out = Vec::new();
for row in rows {
let object_type: String = row.get("object_type");
let object_id: String = row.get("object_id");
let r = ObjectRef::new(object_type, object_id);
if self
.check(&r, permission, subject, Consistency::Minimum)
.await?
.is_allowed()
{
out.push(r);
}
}
Ok(out)
}
async fn lookup_subjects(
&self,
subject_type: &str,
resource: &ObjectRef,
permission: &str,
) -> Result<Vec<SubjectRef>> {
let Some(schema) = self.get_namespace(&resource.object_type).await? else {
return Ok(Vec::new());
};
let Some(resolved) = resolve(&schema, permission) else {
return Ok(Vec::new());
};
if resolved.union_relations.is_empty() {
return Ok(Vec::new());
}
let relation_list: Vec<String> = resolved.union_relations.into_iter().collect();
let rows = sqlx::query(
r#"
WITH RECURSIVE walk(subject_type, subject_id, subject_rel, depth, path) AS (
SELECT t.subject_type, t.subject_id, t.subject_rel, 1,
ARRAY[t.subject_type || ':' || t.subject_id]
FROM auth.zanzibar_tuples t
WHERE t.object_type = $1 AND t.object_id = $2 AND t.relation = ANY($3)
UNION ALL
SELECT t.subject_type, t.subject_id, t.subject_rel, w.depth + 1,
w.path || (t.subject_type || ':' || t.subject_id)
FROM auth.zanzibar_tuples t
JOIN walk w
ON t.object_type = w.subject_type
AND t.object_id = w.subject_id
AND w.subject_rel <> ''
AND t.relation = w.subject_rel
WHERE w.depth < $4
AND NOT (t.subject_type || ':' || t.subject_id) = ANY(w.path)
)
SELECT DISTINCT subject_type, subject_id
FROM walk
WHERE subject_type = $5 AND subject_rel = ''
"#,
)
.bind(&resource.object_type)
.bind(&resource.object_id)
.bind(&relation_list)
.bind(MAX_DEPTH as i32)
.bind(subject_type)
.fetch_all(&self.pool)
.await
.context("auth.zanzibar lookup_subjects CTE")?;
Ok(rows
.into_iter()
.map(|row| {
SubjectRef::direct(
row.get::<String, _>("subject_type"),
row.get::<String, _>("subject_id"),
)
})
.collect())
}
}
impl LeafCheck for PostgresZanzibarStore {
fn check_relation_set<'a>(
&'a self,
object: &'a ObjectRef,
relations: &'a [String],
subject: &'a SubjectRef,
) -> Pin<Box<dyn Future<Output = Result<Verdict>> + Send + 'a>> {
Box::pin(async move {
if relations.is_empty() {
return Ok(Verdict::Denied);
}
let row: Option<(i32,)> = sqlx::query_as(
r#"
WITH RECURSIVE walk(subject_type, subject_id, subject_rel, depth, path) AS (
SELECT t.subject_type,
t.subject_id,
t.subject_rel,
1 AS depth,
ARRAY[t.subject_type || ':' || t.subject_id] AS path
FROM auth.zanzibar_tuples t
WHERE t.object_type = $1 AND t.object_id = $2 AND t.relation = ANY($3)
UNION ALL
SELECT t.subject_type,
t.subject_id,
t.subject_rel,
w.depth + 1,
w.path || (t.subject_type || ':' || t.subject_id)
FROM auth.zanzibar_tuples t
JOIN walk w
ON t.object_type = w.subject_type
AND t.object_id = w.subject_id
AND w.subject_rel <> ''
AND t.relation = w.subject_rel
WHERE w.depth < $4
AND NOT (t.subject_type || ':' || t.subject_id) = ANY(w.path)
)
SELECT CASE
WHEN EXISTS (
SELECT 1 FROM walk
WHERE subject_type = $5 AND subject_id = $6 AND subject_rel = ''
) THEN 1
WHEN EXISTS (SELECT 1 FROM walk WHERE depth >= $4) THEN 2
ELSE 0
END AS verdict
"#,
)
.bind(&object.object_type)
.bind(&object.object_id)
.bind(relations)
.bind(MAX_DEPTH as i32)
.bind(&subject.subject_type)
.bind(&subject.subject_id)
.fetch_optional(&self.pool)
.await
.context("auth.zanzibar check CTE")?;
Ok(match row.map(|(v,)| v).unwrap_or(0) {
1 => Verdict::Allowed,
2 => Verdict::DepthExceeded,
_ => Verdict::Denied,
})
})
}
fn arrow_targets<'a>(
&'a self,
object: &'a ObjectRef,
relation: &'a str,
) -> Pin<Box<dyn Future<Output = Result<Vec<ObjectRef>>> + Send + 'a>> {
Box::pin(async move {
let rows = sqlx::query(
"SELECT subject_type, subject_id
FROM auth.zanzibar_tuples
WHERE object_type = $1 AND object_id = $2 AND relation = $3
AND subject_rel = ''",
)
.bind(&object.object_type)
.bind(&object.object_id)
.bind(relation)
.fetch_all(&self.pool)
.await
.context("auth.zanzibar arrow targets")?;
Ok(rows
.into_iter()
.map(|row| {
ObjectRef::new(
row.get::<String, _>("subject_type"),
row.get::<String, _>("subject_id"),
)
})
.collect())
})
}
fn schema_for<'a>(
&'a self,
object_type: &'a str,
) -> Pin<Box<dyn Future<Output = Result<Option<NamespaceSchema>>> + Send + 'a>> {
Box::pin(async move { self.get_namespace(object_type).await })
}
}
fn expand_pg<'a>(
store: &'a PostgresZanzibarStore,
resource: &'a ObjectRef,
relation: &'a str,
depth: u32,
seen: &'a mut Vec<String>,
) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<Vec<UsersetTree>>> + Send + 'a>> {
Box::pin(async move {
if depth == 0 {
return Ok(Vec::new());
}
let key = format!(
"{}:{}#{}",
resource.object_type, resource.object_id, relation
);
if seen.contains(&key) {
return Ok(Vec::new());
}
seen.push(key);
let rows = sqlx::query(
"SELECT subject_type, subject_id, subject_rel
FROM auth.zanzibar_tuples
WHERE object_type = $1 AND object_id = $2 AND relation = $3",
)
.bind(&resource.object_type)
.bind(&resource.object_id)
.bind(relation)
.fetch_all(&store.pool)
.await
.context("auth.zanzibar_tuples expand fetch")?;
let mut children = Vec::new();
for row in rows {
let st: String = row.get("subject_type");
let sid: String = row.get("subject_id");
let sr: String = row.get("subject_rel");
if sr.is_empty() {
children.push(UsersetTree::Leaf {
subject: SubjectRef::direct(st, sid),
});
} else {
let inner_resource = ObjectRef::new(st.clone(), sid.clone());
let sub = expand_pg(store, &inner_resource, &sr, depth - 1, seen).await?;
children.push(UsersetTree::Node {
op: TreeOp::TuplesetArrow,
children: vec![
UsersetTree::Leaf {
subject: SubjectRef::userset(st, sid, sr.clone()),
},
UsersetTree::Node {
op: TreeOp::Direct,
children: sub,
},
],
});
}
}
Ok(children)
})
}