1use 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}