Skip to main content

postgres_bulk_rebac/
postgres_bulk_rebac.rs

1//! PostgreSQL-backed batch ReBAC example.
2//!
3//! This models a list endpoint with two policies:
4//!
5//! - public posts are visible through an in-memory predicate policy
6//! - private posts are visible when a `viewer` relationship exists in PostgreSQL
7//!
8//! The policy stack stays in Gatehouse. PostgreSQL is only responsible for
9//! loading relationship facts, and `EvaluationSession` batches, deduplicates,
10//! caches, and expands those facts back into caller order.
11
12use async_trait::async_trait;
13use gatehouse::{
14    EvaluationSession, FactLoadError, FactLoadResult, FactRegistry, FactSource, PermissionChecker,
15    PolicyBuilder, PolicyDomain, RebacPolicy, RelationshipQuery,
16};
17use std::fmt;
18use std::sync::Arc;
19use std::time::{Duration, Instant};
20use tokio_postgres::{Client, NoTls, Statement};
21use uuid::Uuid;
22
23type RelationshipKey = RelationshipQuery<Uuid, Uuid, Relation>;
24
25#[derive(Clone)]
26struct User {
27    id: Uuid,
28}
29
30#[derive(Clone)]
31struct Post {
32    id: Uuid,
33    public: bool,
34}
35
36struct View;
37
38struct PostDomain;
39
40impl PolicyDomain for PostDomain {
41    type Subject = User;
42    type Action = View;
43    type Resource = Post;
44    type Context = ();
45}
46
47#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
48enum Relation {
49    Viewer,
50}
51
52impl Relation {
53    fn as_str(self) -> &'static str {
54        match self {
55            Self::Viewer => "viewer",
56        }
57    }
58}
59
60impl fmt::Display for Relation {
61    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
62        f.write_str(self.as_str())
63    }
64}
65
66#[derive(Clone)]
67struct PgRelationshipSource {
68    client: Arc<Client>,
69    point_stmt: Arc<Statement>,
70    bulk_stmt: Arc<Statement>,
71}
72
73impl PgRelationshipSource {
74    async fn load_point(&self, key: &RelationshipKey) -> FactLoadResult<bool> {
75        let relationship = key.relation.as_str();
76        match self
77            .client
78            .query_one(
79                &*self.point_stmt,
80                &[&key.subject_id, &relationship, &key.resource_id],
81            )
82            .await
83        {
84            Ok(row) => FactLoadResult::Found(row.get("allowed")),
85            Err(error) => FactLoadResult::Error(FactLoadError::backend(error)),
86        }
87    }
88
89    async fn load_bulk(&self, keys: &[RelationshipKey]) -> Vec<FactLoadResult<bool>> {
90        let subject_ids = keys.iter().map(|key| key.subject_id).collect::<Vec<_>>();
91        let post_ids = keys.iter().map(|key| key.resource_id).collect::<Vec<_>>();
92        let relationships = keys
93            .iter()
94            .map(|key| key.relation.as_str())
95            .collect::<Vec<_>>();
96
97        match self
98            .client
99            .query(&*self.bulk_stmt, &[&subject_ids, &post_ids, &relationships])
100            .await
101        {
102            Ok(rows) => rows
103                .into_iter()
104                .map(|row| FactLoadResult::Found(row.get("allowed")))
105                .collect(),
106            Err(error) => {
107                let error = FactLoadError::backend(error);
108                keys.iter()
109                    .map(|_| FactLoadResult::Error(error.clone()))
110                    .collect()
111            }
112        }
113    }
114}
115
116#[async_trait]
117impl FactSource<RelationshipKey> for PgRelationshipSource {
118    async fn load_many(&self, keys: &[RelationshipKey]) -> Vec<FactLoadResult<bool>> {
119        if keys.len() == 1 {
120            return vec![self.load_point(&keys[0]).await];
121        }
122
123        self.load_bulk(keys).await
124    }
125}
126
127async fn assert_point_and_bulk_agree(source: &PgRelationshipSource, keys: &[RelationshipKey]) {
128    for key in keys {
129        let point = source.load_point(key).await;
130        let bulk = source
131            .load_bulk(std::slice::from_ref(key))
132            .await
133            .into_iter()
134            .next()
135            .expect("bulk load for one key should return one result");
136
137        match (point, bulk) {
138            (FactLoadResult::Found(point), FactLoadResult::Found(bulk)) => {
139                assert_eq!(point, bulk, "point and bulk SQL should agree for {key:?}");
140            }
141            (point, bulk) => {
142                panic!(
143                    "point and bulk SQL should both succeed in the example: {point:?} vs {bulk:?}"
144                );
145            }
146        }
147    }
148}
149
150fn build_checker() -> PermissionChecker<PostDomain> {
151    let public_posts = PolicyBuilder::<PostDomain>::new("PublicPost")
152        .resources(|post| post.public)
153        .build();
154    let viewer_relationship = RebacPolicy::<PostDomain, Uuid, Uuid, Relation>::new(
155        |user: &User| user.id,
156        |post: &Post| post.id,
157        Relation::Viewer,
158    );
159
160    let mut checker = PermissionChecker::new();
161    checker.add_policy(public_posts);
162    checker.add_policy(viewer_relationship);
163    checker
164}
165
166fn session_with(source: &Arc<dyn FactSource<RelationshipKey>>) -> EvaluationSession {
167    FactRegistry::builder()
168        .with_arc::<RelationshipKey>(Arc::clone(source))
169        .build()
170        .session()
171}
172
173#[tokio::main]
174async fn main() {
175    let database_url = std::env::var("DATABASE_URL").unwrap_or_else(|_| {
176        "host=localhost port=15432 user=postgres password=test dbname=awa_test".to_string()
177    });
178
179    let (client, connection) = tokio_postgres::connect(&database_url, NoTls)
180        .await
181        .expect("connect to PostgreSQL");
182    tokio::spawn(async move {
183        if let Err(error) = connection.await {
184            eprintln!("postgres connection error: {error}");
185        }
186    });
187    let client = Arc::new(client);
188
189    let version: String = client
190        .query_one("SELECT version()", &[])
191        .await
192        .expect("version query should succeed")
193        .get(0);
194    println!("{version}");
195
196    client
197        .batch_execute(
198            "
199            DROP TABLE IF EXISTS gatehouse_example_post_relationships;
200            CREATE UNLOGGED TABLE gatehouse_example_post_relationships (
201                subject_id uuid NOT NULL,
202                post_id uuid NOT NULL,
203                relationship text NOT NULL,
204                PRIMARY KEY (subject_id, post_id, relationship)
205            );
206            ",
207        )
208        .await
209        .expect("setup schema");
210
211    let subject = User { id: Uuid::new_v4() };
212    let posts = (0..10_000)
213        .map(|index| Post {
214            id: Uuid::new_v4(),
215            public: index % 5 == 0,
216        })
217        .collect::<Vec<_>>();
218    let granted_ids = posts
219        .iter()
220        .enumerate()
221        .filter_map(|(index, post)| (!post.public && index % 2 == 0).then_some(post.id))
222        .collect::<Vec<_>>();
223    let relationships = vec![Relation::Viewer.as_str(); granted_ids.len()];
224    let subject_ids = vec![subject.id; granted_ids.len()];
225
226    client
227        .execute(
228            "
229            INSERT INTO gatehouse_example_post_relationships (subject_id, post_id, relationship)
230            SELECT *
231            FROM unnest($1::uuid[], $2::uuid[], $3::text[])
232            ",
233            &[&subject_ids, &granted_ids, &relationships],
234        )
235        .await
236        .expect("seed grants");
237
238    let point_stmt = Arc::new(
239        client
240            .prepare(
241                "
242                SELECT EXISTS (
243                    SELECT 1
244                    FROM gatehouse_example_post_relationships
245                    WHERE subject_id = $1
246                      AND relationship = $2
247                      AND post_id = $3
248                ) AS allowed
249                ",
250            )
251            .await
252            .expect("prepare point query"),
253    );
254    let bulk_stmt = Arc::new(
255        client
256            .prepare(
257                "
258                WITH candidate_relationships AS (
259                    SELECT subject_id, post_id, relationship, ord
260                    FROM unnest($1::uuid[], $2::uuid[], $3::text[])
261                        WITH ORDINALITY AS input(subject_id, post_id, relationship, ord)
262                )
263                SELECT
264                    COALESCE(bool_or(g.post_id IS NOT NULL), false) AS allowed
265                FROM candidate_relationships c
266                LEFT JOIN gatehouse_example_post_relationships g
267                  ON g.subject_id = c.subject_id
268                 AND g.relationship = c.relationship
269                 AND g.post_id = c.post_id
270                GROUP BY c.ord, c.subject_id, c.post_id, c.relationship
271                ORDER BY c.ord
272                ",
273            )
274            .await
275            .expect("prepare bulk query"),
276    );
277
278    let source = Arc::new(PgRelationshipSource {
279        client,
280        point_stmt,
281        bulk_stmt,
282    });
283    assert_point_and_bulk_agree(
284        &source,
285        &[
286            RelationshipQuery {
287                subject_id: subject.id,
288                resource_id: posts
289                    .iter()
290                    .find(|post| granted_ids.contains(&post.id))
291                    .expect("fixture should include a granted private post")
292                    .id,
293                relation: Relation::Viewer,
294            },
295            RelationshipQuery {
296                subject_id: subject.id,
297                resource_id: posts
298                    .iter()
299                    .enumerate()
300                    .find(|(index, post)| !post.public && index % 2 == 1)
301                    .expect("fixture should include a denied private post")
302                    .1
303                    .id,
304                relation: Relation::Viewer,
305            },
306        ],
307    )
308    .await;
309    let source: Arc<dyn FactSource<RelationshipKey>> = source;
310    let checker = build_checker();
311
312    println!("size,relationship_checks,naive_ms,bulk_ms,allowed,improvement");
313    for &size in &[10usize, 100, 1_000, 5_000, 10_000] {
314        let sample = posts.iter().take(size).cloned().collect::<Vec<_>>();
315        let relationship_checks = sample.iter().filter(|post| !post.public).count();
316        let naive = measure(|| async {
317            let mut allowed = 0usize;
318            for post in &sample {
319                let session = session_with(&source);
320                if checker
321                    .bind(&session, &subject, &View, &())
322                    .check(post)
323                    .await
324                    .is_granted()
325                {
326                    allowed += 1;
327                }
328            }
329            allowed
330        })
331        .await;
332
333        let bulk = measure(|| async {
334            let session = session_with(&source);
335            checker
336                .bind(&session, &subject, &View, &())
337                .filter(sample.clone())
338                .await
339                .len()
340        })
341        .await;
342
343        assert_eq!(naive.output, bulk.output);
344        println!(
345            "{size},{relationship_checks},{:.3},{:.3},{},x{:.1}",
346            naive.elapsed.as_secs_f64() * 1_000.0,
347            bulk.elapsed.as_secs_f64() * 1_000.0,
348            bulk.output,
349            naive.elapsed.as_secs_f64() / bulk.elapsed.as_secs_f64()
350        );
351    }
352}
353
354struct Measurement<T> {
355    elapsed: Duration,
356    output: T,
357}
358
359async fn measure<F, Fut, T>(mut f: F) -> Measurement<T>
360where
361    F: FnMut() -> Fut,
362    Fut: std::future::Future<Output = T>,
363{
364    let mut best_elapsed = Duration::MAX;
365    let mut best_output = None;
366
367    for _ in 0..3 {
368        let start = Instant::now();
369        let output = f().await;
370        let elapsed = start.elapsed();
371        if elapsed < best_elapsed {
372            best_elapsed = elapsed;
373            best_output = Some(output);
374        }
375    }
376
377    Measurement {
378        elapsed: best_elapsed,
379        output: best_output.expect("measurement should run at least once"),
380    }
381}