Skip to main content

factsource_n_plus_one/
factsource_n_plus_one.rs

1//! Contrastive example: the FactSource N+1 trap, and the fix.
2//!
3//! This is a **teaching artifact**, not a recipe to copy. It shows the
4//! failure mode an author falls into when they reach for the obvious
5//! shape — holding an `Arc<SomeBackend>` on the policy and calling it
6//! directly from `evaluate` — and the deduped shape that fixes it.
7//!
8//! Scenario: a supplier-only policy needs to resolve the subject's
9//! `org_id` to its billing `customer_id` to decide whether the
10//! caller's org owns the invoice. The mapping is fixed for the
11//! request — same answer for every invoice in a list endpoint — but
12//! the obvious shape calls the backend once per resource anyway.
13//!
14//! Identity note: there is no user-to-org translation in this file.
15//! A real app has already authenticated the request and built the
16//! Gatehouse subject, `Supplier { user_id, org_id }`. This policy uses
17//! `org_id` because the authorization question is org-scoped; `user_id`
18//! is present only to show where the caller identity would live.
19//!
20//! Run with:
21//!
22//! ```text
23//! cargo run --example factsource_n_plus_one
24//! ```
25//!
26//! Expected output (the numbers are the load — not lines emitted, but
27//! actual hierarchy backend calls):
28//!
29//! ```text
30//! [wrong] 25 invoices -> 25 hierarchy lookups (N+1, redundant)
31//! [right] 25 invoices ->  1 hierarchy lookup  (deduped through the session)
32//! ```
33//!
34//! The fix is *not* "cache inside `HierarchyService`" — that works for
35//! the single request but leaks cross-request, and most teams don't
36//! own the hierarchy service code. The fix is "ask the session for the
37//! fact"; the session owns request-scoped dedup and is dropped when
38//! the request ends.
39
40use async_trait::async_trait;
41use gatehouse::{
42    EvalCtx, EvaluationSession, FactKey, FactLoadResult, FactSource, PermissionChecker, Policy,
43    PolicyEvalResult,
44};
45use std::borrow::Cow;
46use std::sync::atomic::{AtomicUsize, Ordering};
47use std::sync::Arc;
48use uuid::Uuid;
49
50// ---- domain --------------------------------------------------------
51
52/// Authenticated caller plus the supplier organization they are acting for.
53///
54/// The example keeps `user_id` in the subject shape, but the rule below is
55/// deliberately organization-scoped: it asks whether this supplier org rolls
56/// up to the invoice customer.
57#[derive(Debug, Clone)]
58struct Supplier {
59    #[allow(dead_code)]
60    user_id: Uuid,
61    org_id: Uuid,
62}
63
64#[derive(Debug, Clone)]
65struct Invoice {
66    #[allow(dead_code)]
67    id: Uuid,
68    customer_id: Uuid,
69}
70
71#[derive(Debug, Clone)]
72struct ViewAction;
73
74// ---- backend service the policy needs to consult ------------------
75
76/// Stand-in for a real hierarchy service. The atomic call counter is
77/// the load-bearing piece of the example — it makes the N+1 visible
78/// at runtime.
79struct HierarchyService {
80    /// Maps org id -> customer (billing parent) id.
81    routes: std::collections::HashMap<Uuid, Uuid>,
82    /// Counts every call to [`Self::resolve_customer`]. We assert on
83    /// this at the end of the example.
84    call_count: AtomicUsize,
85}
86
87impl HierarchyService {
88    fn new(routes: std::collections::HashMap<Uuid, Uuid>) -> Self {
89        Self {
90            routes,
91            call_count: AtomicUsize::new(0),
92        }
93    }
94
95    async fn resolve_customer(&self, org_id: Uuid) -> Option<Uuid> {
96        self.call_count.fetch_add(1, Ordering::SeqCst);
97        // Simulate a network round trip.
98        tokio::task::yield_now().await;
99        self.routes.get(&org_id).copied()
100    }
101
102    fn calls(&self) -> usize {
103        self.call_count.load(Ordering::SeqCst)
104    }
105
106    fn reset(&self) {
107        self.call_count.store(0, Ordering::SeqCst);
108    }
109}
110
111// ---- WRONG shape: backend called from inside Policy::evaluate -----
112
113/// The shape an author writes when reaching for the obvious tool.
114/// Holds the hierarchy as a struct field; calls it per invocation.
115/// For a list of N invoices, this fires N redundant lookups because
116/// the answer for a given `org_id` doesn't depend on the invoice.
117struct WrongSupplierPolicy {
118    hierarchy: Arc<HierarchyService>,
119}
120
121#[async_trait]
122impl Policy<Supplier, Invoice, ViewAction, ()> for WrongSupplierPolicy {
123    async fn evaluate(
124        &self,
125        ctx: &EvalCtx<'_, Supplier, Invoice, ViewAction, ()>,
126    ) -> PolicyEvalResult {
127        // N+1: every item in a batch re-asks the hierarchy for the
128        // same org -> customer mapping.
129        let resolved = self.hierarchy.resolve_customer(ctx.subject.org_id).await;
130        match resolved {
131            Some(customer_id) if customer_id == ctx.resource.customer_id => {
132                ctx.grant("subject's supplier org bills under the invoice's customer")
133            }
134            _ => ctx.deny("subject's supplier org does not bill under the invoice's customer"),
135        }
136    }
137    fn policy_type(&self) -> Cow<'static, str> {
138        Cow::Borrowed("WrongSupplierPolicy")
139    }
140}
141
142// ---- RIGHT shape: FactSource consulted through the session --------
143
144/// One fact key per question. The session deduplicates by this key,
145/// so a 25-invoice batch with one supplier subject produces one
146/// `load_many([CustomerForOrg(org_id)])` call regardless of how many
147/// times the policy asks.
148#[derive(Debug, Clone, Hash, PartialEq, Eq)]
149struct CustomerForOrg(Uuid);
150
151impl FactKey for CustomerForOrg {
152    const NAME: &'static str = "customer_for_org";
153    type Value = Option<Uuid>;
154}
155
156/// Adapter from the existing backend service to the FactSource shape.
157/// In production this is where authors plug a DataLoader or a SQL
158/// batch query. Here we delegate back to `HierarchyService` so the
159/// call counter measures the same thing as the WRONG path.
160struct CustomerForOrgSource {
161    hierarchy: Arc<HierarchyService>,
162    /// Counts how many times the session invokes `load_many`. This is the
163    /// *batching* lesson, distinct from the backend call count: 25 invoices
164    /// produce one `load_many` call (covering the unique key set), not 25.
165    load_many_calls: Arc<AtomicUsize>,
166}
167
168#[async_trait]
169impl FactSource<CustomerForOrg> for CustomerForOrgSource {
170    async fn load_many(&self, keys: &[CustomerForOrg]) -> Vec<FactLoadResult<Option<Uuid>>> {
171        self.load_many_calls.fetch_add(1, Ordering::SeqCst);
172        // The session has already deduplicated; `keys` are unique.
173        // For the example we just loop, but a real source would issue
174        // one SQL query / DataLoader batch covering every key.
175        let mut out = Vec::with_capacity(keys.len());
176        for CustomerForOrg(org_id) in keys {
177            out.push(FactLoadResult::Found(
178                self.hierarchy.resolve_customer(*org_id).await,
179            ));
180        }
181        out
182    }
183}
184
185struct RightSupplierPolicy;
186
187#[async_trait]
188impl Policy<Supplier, Invoice, ViewAction, ()> for RightSupplierPolicy {
189    async fn evaluate(
190        &self,
191        ctx: &EvalCtx<'_, Supplier, Invoice, ViewAction, ()>,
192    ) -> PolicyEvalResult {
193        // Ask the session, not the backend service directly. The
194        // first call inside this request triggers `load_many`; every
195        // subsequent call with the same key (e.g. another invoice in
196        // the same batch) hits the request-scoped cache.
197        match ctx.session.get(CustomerForOrg(ctx.subject.org_id)).await {
198            FactLoadResult::Found(Some(customer_id)) if customer_id == ctx.resource.customer_id => {
199                ctx.grant("subject's supplier org bills under the invoice's customer")
200            }
201            _ => ctx.deny("subject's supplier org does not bill under the invoice's customer"),
202        }
203    }
204    fn policy_type(&self) -> Cow<'static, str> {
205        Cow::Borrowed("RightSupplierPolicy")
206    }
207}
208
209// ---- driver --------------------------------------------------------
210
211#[tokio::main]
212async fn main() {
213    // Same supplier, same hierarchy, same invoices for both shapes.
214    let supplier_org = Uuid::new_v4();
215    let customer = Uuid::new_v4();
216    let supplier = Supplier {
217        user_id: Uuid::new_v4(),
218        org_id: supplier_org,
219    };
220    let routes = std::collections::HashMap::from([(supplier_org, customer)]);
221    let hierarchy = Arc::new(HierarchyService::new(routes));
222
223    let invoices: Vec<Invoice> = (0..25)
224        .map(|_| Invoice {
225            id: Uuid::new_v4(),
226            customer_id: customer,
227        })
228        .collect();
229
230    // ---- WRONG ----
231    let mut wrong_checker = PermissionChecker::<Supplier, Invoice, ViewAction, ()>::new();
232    wrong_checker.add_policy(WrongSupplierPolicy {
233        hierarchy: Arc::clone(&hierarchy),
234    });
235
236    hierarchy.reset();
237    let session = EvaluationSession::empty();
238    let visible = wrong_checker
239        .filter_authorized_in_session_by_resource(
240            &session,
241            &supplier,
242            &ViewAction,
243            invoices.clone(),
244            &(),
245            |i| i,
246        )
247        .await;
248    let wrong_calls = hierarchy.calls();
249    println!(
250        "[wrong] {} invoices -> {} hierarchy lookups (N+1, redundant)",
251        visible.len(),
252        wrong_calls,
253    );
254    // Check the lesson (call count) before the bookkeeping (item count)
255    // so a regression in the dedup logic surfaces here, not in a
256    // confusing length mismatch.
257    assert_eq!(
258        wrong_calls, 25,
259        "the wrong shape pays one hierarchy call per item",
260    );
261    assert_eq!(visible.len(), 25);
262
263    // ---- RIGHT ----
264    let mut right_checker = PermissionChecker::<Supplier, Invoice, ViewAction, ()>::new();
265    right_checker.add_policy(RightSupplierPolicy);
266
267    hierarchy.reset();
268    let load_many_calls = Arc::new(AtomicUsize::new(0));
269    let session = EvaluationSession::builder()
270        .with_arc::<CustomerForOrg>(Arc::new(CustomerForOrgSource {
271            hierarchy: Arc::clone(&hierarchy),
272            load_many_calls: Arc::clone(&load_many_calls),
273        }))
274        .build();
275    let visible = right_checker
276        .filter_authorized_in_session_by_resource(
277            &session,
278            &supplier,
279            &ViewAction,
280            invoices,
281            &(),
282            |i| i,
283        )
284        .await;
285    let right_calls = hierarchy.calls();
286    let batch_calls = load_many_calls.load(Ordering::SeqCst);
287    println!(
288        "[right] {} invoices ->  {} hierarchy lookup  ({} batched load_many call, deduped through the session)",
289        visible.len(),
290        right_calls,
291        batch_calls,
292    );
293    assert_eq!(
294        right_calls, 1,
295        "the session deduplicates: one supplier_org, one backend call",
296    );
297    assert_eq!(
298        batch_calls, 1,
299        "the session batches: one load_many call covering the unique key set",
300    );
301    assert_eq!(visible.len(), 25);
302}