Skip to main content

assay_auth/zanzibar/
postgres.rs

1//! Postgres [`ZanzibarStore`] implementation — recursive CTE walks
2//! over `auth.zanzibar_tuples` for `check`, `expand`, and the lookup
3//! pair.
4//!
5//! Layout: a single round-trip per call. The CTE seeds with the
6//! direct tuples on the resource for whatever relation set the
7//! [`super::resolve`] pass produced, then expands one userset hop at
8//! a time bounded by [`super::types::MAX_DEPTH`]. Cycle detection
9//! uses an in-CTE `path` array — if the next subject is already on
10//! the path, the join skips it.
11//!
12//! Performance notes (PG18 EXPLAIN-checked once during
13//! development against a 100k-tuple seed):
14//!
15//! - Forward walk hits the composite PK `(object_type, object_id,
16//!   relation, …)` for the seed and then for each hop.
17//! - Reverse `lookup_resources` hits `idx_auth_zanzibar_tuples_rev`
18//!   for the seed; subsequent hops still use the PK because the next
19//!   step is "given subject, find object" → forward direction again.
20//! - JSONB `auth.zanzibar_namespaces.schema_json` is round-tripped
21//!   via `serde_json::Value` (sqlx's `JsonValue` mapping); for the
22//!   100-namespace order-of-magnitude any v0.2.0 deployment will
23//!   actually have, parsing on every check is fine — the schema
24//!   cache is a phase-9 follow-up.
25
26use std::sync::Arc;
27
28use anyhow::{Context, Result};
29use sqlx::{PgPool, Row};
30
31use super::resolve::resolve;
32use super::store::ZanzibarStore;
33use super::types::{
34    CheckResult, Consistency, MAX_DEPTH, NamespaceSchema, ObjectRef, SubjectRef, TreeOp, Tuple,
35    UsersetTree,
36};
37
38/// Postgres-backed Zanzibar store. Cheap to clone (the underlying
39/// `PgPool` is `Arc` internally).
40#[derive(Clone)]
41pub struct PostgresZanzibarStore {
42    pool: PgPool,
43}
44
45impl PostgresZanzibarStore {
46    pub fn new(pool: PgPool) -> Self {
47        Self { pool }
48    }
49
50    /// Wrap into an `Arc<dyn ZanzibarStore>` for [`crate::ctx::AuthCtx`].
51    pub fn into_dyn(self) -> Arc<dyn ZanzibarStore> {
52        Arc::new(self)
53    }
54}
55
56#[async_trait::async_trait]
57impl ZanzibarStore for PostgresZanzibarStore {
58    async fn define_namespace(&self, schema: &NamespaceSchema) -> Result<()> {
59        let json = serde_json::to_value(schema).context("zanzibar serialize NamespaceSchema")?;
60        sqlx::query(
61            "INSERT INTO auth.zanzibar_namespaces (name, schema_json, updated_at)
62             VALUES ($1, $2, EXTRACT(EPOCH FROM NOW()))
63             ON CONFLICT (name) DO UPDATE
64                 SET schema_json = EXCLUDED.schema_json,
65                     updated_at = EXCLUDED.updated_at",
66        )
67        .bind(&schema.name)
68        .bind(json)
69        .execute(&self.pool)
70        .await
71        .context("auth.zanzibar_namespaces upsert")?;
72        Ok(())
73    }
74
75    async fn get_namespace(&self, name: &str) -> Result<Option<NamespaceSchema>> {
76        let row: Option<(serde_json::Value,)> =
77            sqlx::query_as("SELECT schema_json FROM auth.zanzibar_namespaces WHERE name = $1")
78                .bind(name)
79                .fetch_optional(&self.pool)
80                .await
81                .context("auth.zanzibar_namespaces get")?;
82        Ok(match row {
83            Some((json,)) => {
84                Some(serde_json::from_value(json).context("zanzibar deserialize NamespaceSchema")?)
85            }
86            None => None,
87        })
88    }
89
90    async fn list_namespaces(&self) -> Result<Vec<NamespaceSchema>> {
91        let rows: Vec<(serde_json::Value,)> =
92            sqlx::query_as("SELECT schema_json FROM auth.zanzibar_namespaces ORDER BY name")
93                .fetch_all(&self.pool)
94                .await
95                .context("auth.zanzibar_namespaces list")?;
96        rows.into_iter()
97            .map(|(json,)| {
98                serde_json::from_value(json).context("zanzibar deserialize NamespaceSchema")
99            })
100            .collect()
101    }
102
103    async fn write_tuple(&self, t: &Tuple) -> Result<()> {
104        sqlx::query(
105            "INSERT INTO auth.zanzibar_tuples
106                (object_type, object_id, relation,
107                 subject_type, subject_id, subject_rel, created_at)
108             VALUES ($1, $2, $3, $4, $5, $6, EXTRACT(EPOCH FROM NOW()))
109             ON CONFLICT DO NOTHING",
110        )
111        .bind(&t.object_type)
112        .bind(&t.object_id)
113        .bind(&t.relation)
114        .bind(&t.subject_type)
115        .bind(&t.subject_id)
116        .bind(&t.subject_rel)
117        .execute(&self.pool)
118        .await
119        .context("auth.zanzibar_tuples insert")?;
120        Ok(())
121    }
122
123    async fn write_tuples(&self, tuples: &[Tuple]) -> Result<()> {
124        if tuples.is_empty() {
125            return Ok(());
126        }
127        let mut tx = self.pool.begin().await.context("begin tuples txn")?;
128        for t in tuples {
129            sqlx::query(
130                "INSERT INTO auth.zanzibar_tuples
131                    (object_type, object_id, relation,
132                     subject_type, subject_id, subject_rel, created_at)
133                 VALUES ($1, $2, $3, $4, $5, $6, EXTRACT(EPOCH FROM NOW()))
134                 ON CONFLICT DO NOTHING",
135            )
136            .bind(&t.object_type)
137            .bind(&t.object_id)
138            .bind(&t.relation)
139            .bind(&t.subject_type)
140            .bind(&t.subject_id)
141            .bind(&t.subject_rel)
142            .execute(&mut *tx)
143            .await
144            .context("auth.zanzibar_tuples batch insert")?;
145        }
146        tx.commit().await.context("commit tuples txn")?;
147        Ok(())
148    }
149
150    async fn delete_tuple(&self, t: &Tuple) -> Result<bool> {
151        // subject_rel is NOT NULL ('' for direct), so plain equality
152        // suffices — no IS NOT DISTINCT FROM dance.
153        let res = sqlx::query(
154            "DELETE FROM auth.zanzibar_tuples
155             WHERE object_type = $1 AND object_id = $2 AND relation = $3
156               AND subject_type = $4 AND subject_id = $5
157               AND subject_rel = $6",
158        )
159        .bind(&t.object_type)
160        .bind(&t.object_id)
161        .bind(&t.relation)
162        .bind(&t.subject_type)
163        .bind(&t.subject_id)
164        .bind(&t.subject_rel)
165        .execute(&self.pool)
166        .await
167        .context("auth.zanzibar_tuples delete")?;
168        Ok(res.rows_affected() > 0)
169    }
170
171    async fn check(
172        &self,
173        resource: &ObjectRef,
174        permission: &str,
175        subject: &SubjectRef,
176        _consistency: Consistency,
177    ) -> Result<CheckResult> {
178        // 1) Resolve the permission to its candidate relation set.
179        //    No namespace defined → deny (the safe default).
180        let Some(schema) = self.get_namespace(&resource.object_type).await? else {
181            return Ok(CheckResult::Denied);
182        };
183        let Some(resolved) = resolve(&schema, permission) else {
184            // Schema cycle. Surface as `CycleDetected` so callers/UI
185            // can flag the bad schema; this is *not* an error.
186            return Ok(CheckResult::CycleDetected);
187        };
188        if resolved.union_relations.is_empty() {
189            return Ok(CheckResult::Denied);
190        }
191        let relation_list: Vec<String> = resolved.union_relations.into_iter().collect();
192
193        // 2) Run the recursive walk. subject_rel is NOT NULL — '' for
194        //    direct subjects (the terminal kind `check` answers) and a
195        //    relation name for usersets the CTE walks through. Cycle
196        //    guard via the in-CTE `path` array — if the next subject is
197        //    already on the path, the join produces zero rows.
198        let row: Option<(i32,)> = sqlx::query_as(
199            r#"
200            WITH RECURSIVE walk(subject_type, subject_id, subject_rel, depth, path) AS (
201                SELECT t.subject_type,
202                       t.subject_id,
203                       t.subject_rel,
204                       1 AS depth,
205                       ARRAY[t.subject_type || ':' || t.subject_id] AS path
206                FROM auth.zanzibar_tuples t
207                WHERE t.object_type = $1 AND t.object_id = $2 AND t.relation = ANY($3)
208                UNION ALL
209                SELECT t.subject_type,
210                       t.subject_id,
211                       t.subject_rel,
212                       w.depth + 1,
213                       w.path || (t.subject_type || ':' || t.subject_id)
214                FROM auth.zanzibar_tuples t
215                JOIN walk w
216                  ON t.object_type = w.subject_type
217                 AND t.object_id   = w.subject_id
218                 AND w.subject_rel <> ''
219                 AND t.relation = w.subject_rel
220                WHERE w.depth < $4
221                  AND NOT (t.subject_type || ':' || t.subject_id) = ANY(w.path)
222            )
223            SELECT CASE
224                WHEN EXISTS (
225                    SELECT 1 FROM walk
226                    WHERE subject_type = $5 AND subject_id = $6 AND subject_rel = ''
227                ) THEN 1
228                WHEN EXISTS (SELECT 1 FROM walk WHERE depth >= $4) THEN 2
229                ELSE 0
230            END AS verdict
231            "#,
232        )
233        .bind(&resource.object_type)
234        .bind(&resource.object_id)
235        .bind(&relation_list)
236        .bind(MAX_DEPTH as i32)
237        .bind(&subject.subject_type)
238        .bind(&subject.subject_id)
239        .fetch_optional(&self.pool)
240        .await
241        .context("auth.zanzibar check CTE")?;
242
243        let verdict = row.map(|(v,)| v).unwrap_or(0);
244        Ok(match verdict {
245            1 => CheckResult::Allowed {
246                resolved_via: Vec::new(),
247            },
248            2 => CheckResult::DepthExceeded,
249            _ => CheckResult::Denied,
250        })
251    }
252
253    async fn expand(
254        &self,
255        resource: &ObjectRef,
256        relation: &str,
257        depth_limit: u32,
258    ) -> Result<UsersetTree> {
259        // Single-relation expansion — pull every direct subject of
260        // `resource#relation`, then for each userset hop, recurse.
261        // Diagnostic-only path; we keep the implementation simple
262        // (recursive Rust calls) rather than another CTE.
263        let depth = depth_limit.min(MAX_DEPTH);
264        Ok(UsersetTree::Node {
265            op: TreeOp::Direct,
266            children: expand_pg(self, resource, relation, depth, &mut Vec::new()).await?,
267        })
268    }
269
270    async fn lookup_resources(
271        &self,
272        resource_type: &str,
273        permission: &str,
274        subject: &SubjectRef,
275    ) -> Result<Vec<ObjectRef>> {
276        // Cheap heuristic for v0.2.0: we walk forward from every
277        // resource of the requested type that has *any* tuple in the
278        // candidate relation set, then post-filter via `check`.
279        // Optimal for small fan-outs (the family/circle scale jeebon
280        // ships at); a real production deployment will want a
281        // dedicated reverse index.
282        let Some(schema) = self.get_namespace(resource_type).await? else {
283            return Ok(Vec::new());
284        };
285        let Some(resolved) = resolve(&schema, permission) else {
286            return Ok(Vec::new());
287        };
288        if resolved.union_relations.is_empty() {
289            return Ok(Vec::new());
290        }
291        let relation_list: Vec<String> = resolved.union_relations.into_iter().collect();
292        // First pass: all candidate resources (objects with a tuple in
293        // the relation set, regardless of subject — narrows the search
294        // to one per resource).
295        let rows = sqlx::query(
296            "SELECT DISTINCT object_type, object_id
297             FROM auth.zanzibar_tuples
298             WHERE object_type = $1 AND relation = ANY($2)",
299        )
300        .bind(resource_type)
301        .bind(&relation_list)
302        .fetch_all(&self.pool)
303        .await
304        .context("auth.zanzibar_tuples candidate resources")?;
305        let mut out = Vec::new();
306        for row in rows {
307            let object_type: String = row.get("object_type");
308            let object_id: String = row.get("object_id");
309            let r = ObjectRef::new(object_type, object_id);
310            if self
311                .check(&r, permission, subject, Consistency::Minimum)
312                .await?
313                .is_allowed()
314            {
315                out.push(r);
316            }
317        }
318        Ok(out)
319    }
320
321    async fn lookup_subjects(
322        &self,
323        subject_type: &str,
324        resource: &ObjectRef,
325        permission: &str,
326    ) -> Result<Vec<SubjectRef>> {
327        // Forward walk from the resource — every direct subject under
328        // the candidate relation set. Userset intermediates are
329        // followed transitively via the same recursive CTE as `check`.
330        let Some(schema) = self.get_namespace(&resource.object_type).await? else {
331            return Ok(Vec::new());
332        };
333        let Some(resolved) = resolve(&schema, permission) else {
334            return Ok(Vec::new());
335        };
336        if resolved.union_relations.is_empty() {
337            return Ok(Vec::new());
338        }
339        let relation_list: Vec<String> = resolved.union_relations.into_iter().collect();
340        let rows = sqlx::query(
341            r#"
342            WITH RECURSIVE walk(subject_type, subject_id, subject_rel, depth, path) AS (
343                SELECT t.subject_type, t.subject_id, t.subject_rel, 1,
344                       ARRAY[t.subject_type || ':' || t.subject_id]
345                FROM auth.zanzibar_tuples t
346                WHERE t.object_type = $1 AND t.object_id = $2 AND t.relation = ANY($3)
347                UNION ALL
348                SELECT t.subject_type, t.subject_id, t.subject_rel, w.depth + 1,
349                       w.path || (t.subject_type || ':' || t.subject_id)
350                FROM auth.zanzibar_tuples t
351                JOIN walk w
352                  ON t.object_type = w.subject_type
353                 AND t.object_id   = w.subject_id
354                 AND w.subject_rel <> ''
355                 AND t.relation = w.subject_rel
356                WHERE w.depth < $4
357                  AND NOT (t.subject_type || ':' || t.subject_id) = ANY(w.path)
358            )
359            SELECT DISTINCT subject_type, subject_id
360            FROM walk
361            WHERE subject_type = $5 AND subject_rel = ''
362            "#,
363        )
364        .bind(&resource.object_type)
365        .bind(&resource.object_id)
366        .bind(&relation_list)
367        .bind(MAX_DEPTH as i32)
368        .bind(subject_type)
369        .fetch_all(&self.pool)
370        .await
371        .context("auth.zanzibar lookup_subjects CTE")?;
372        Ok(rows
373            .into_iter()
374            .map(|row| {
375                SubjectRef::direct(
376                    row.get::<String, _>("subject_type"),
377                    row.get::<String, _>("subject_id"),
378                )
379            })
380            .collect())
381    }
382}
383
384/// Recursive helper for `expand` — fetches direct tuples and recurses
385/// into userset subjects up to `depth`.
386fn expand_pg<'a>(
387    store: &'a PostgresZanzibarStore,
388    resource: &'a ObjectRef,
389    relation: &'a str,
390    depth: u32,
391    seen: &'a mut Vec<String>,
392) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<Vec<UsersetTree>>> + Send + 'a>> {
393    Box::pin(async move {
394        if depth == 0 {
395            return Ok(Vec::new());
396        }
397        let key = format!(
398            "{}:{}#{}",
399            resource.object_type, resource.object_id, relation
400        );
401        if seen.contains(&key) {
402            return Ok(Vec::new());
403        }
404        seen.push(key);
405        let rows = sqlx::query(
406            "SELECT subject_type, subject_id, subject_rel
407             FROM auth.zanzibar_tuples
408             WHERE object_type = $1 AND object_id = $2 AND relation = $3",
409        )
410        .bind(&resource.object_type)
411        .bind(&resource.object_id)
412        .bind(relation)
413        .fetch_all(&store.pool)
414        .await
415        .context("auth.zanzibar_tuples expand fetch")?;
416        let mut children = Vec::new();
417        for row in rows {
418            let st: String = row.get("subject_type");
419            let sid: String = row.get("subject_id");
420            let sr: String = row.get("subject_rel");
421            if sr.is_empty() {
422                children.push(UsersetTree::Leaf {
423                    subject: SubjectRef::direct(st, sid),
424                });
425            } else {
426                let inner_resource = ObjectRef::new(st.clone(), sid.clone());
427                let sub = expand_pg(store, &inner_resource, &sr, depth - 1, seen).await?;
428                children.push(UsersetTree::Node {
429                    op: TreeOp::TuplesetArrow,
430                    children: vec![
431                        UsersetTree::Leaf {
432                            subject: SubjectRef::userset(st, sid, sr.clone()),
433                        },
434                        UsersetTree::Node {
435                            op: TreeOp::Direct,
436                            children: sub,
437                        },
438                    ],
439                });
440            }
441        }
442        Ok(children)
443    })
444}