Skip to main content

rebac_policy/
rebac_policy.rs

1//! # Relationship-Based Access Control Policy Example
2//!
3//! This example demonstrates ReBAC: `RebacPolicy` extracts flat IDs and loads
4//! relationship facts through a request-scoped `EvaluationSession`. The happy
5//! path declares sources once in a `FactRegistry`, then creates a fresh session
6//! from that registry for each request.
7//!
8//! Relations are a domain enum (`Relation::Owner`), not strings: the session
9//! deduplicates and caches by the typed `RelationshipQuery` key, the compiler
10//! checks relation names, and any backend-specific serialization stays inside
11//! the `FactSource`. Relationship store failures are returned as
12//! `FactLoadResult::Error` and fail closed to denial — asserted at the end.
13//!
14//! To run this example:
15//! ```
16//! cargo run --example rebac_policy
17//! ```
18
19use async_trait::async_trait;
20use gatehouse::*;
21use std::collections::HashSet;
22use std::fmt;
23use uuid::Uuid;
24
25#[derive(Debug, Clone)]
26struct User {
27    id: Uuid,
28    name: &'static str,
29}
30
31#[derive(Debug, Clone)]
32struct Project {
33    id: Uuid,
34    name: &'static str,
35}
36
37#[derive(Debug, Clone)]
38struct EditAction;
39
40struct ProjectDomain;
41
42impl PolicyDomain for ProjectDomain {
43    type Subject = User;
44    type Action = EditAction;
45    type Resource = Project;
46    type Context = ();
47}
48
49#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
50enum Relation {
51    Owner,
52    Contributor,
53    Viewer,
54}
55
56impl fmt::Display for Relation {
57    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
58        match self {
59            Relation::Owner => write!(f, "owner"),
60            Relation::Contributor => write!(f, "contributor"),
61            Relation::Viewer => write!(f, "viewer"),
62        }
63    }
64}
65
66type ProjectRelationship = RelationshipQuery<Uuid, Uuid, Relation>;
67
68/// In-memory relationship store. The print in `load_many` makes the session's
69/// behaviour visible when the example runs: each unique key is loaded once
70/// per session, however many policies ask about it.
71#[derive(Debug, Clone)]
72struct ProjectRelationshipSource {
73    relationships: HashSet<ProjectRelationship>,
74    fail: bool,
75}
76
77impl ProjectRelationshipSource {
78    fn new(relationships: HashSet<ProjectRelationship>) -> Self {
79        Self {
80            relationships,
81            fail: false,
82        }
83    }
84
85    /// Simulate a relationship store outage for the fail-closed section.
86    fn with_error(mut self) -> Self {
87        self.fail = true;
88        self
89    }
90}
91
92#[async_trait]
93impl FactSource<ProjectRelationship> for ProjectRelationshipSource {
94    async fn load_many(&self, keys: &[ProjectRelationship]) -> Vec<FactLoadResult<bool>> {
95        keys.iter()
96            .map(|key| {
97                println!(
98                    "  loading fact: subject={} relation={} resource={}",
99                    key.subject_id, key.relation, key.resource_id
100                );
101                if self.fail {
102                    FactLoadResult::Error(FactLoadError::backend_message(
103                        "simulated relationship store error",
104                    ))
105                } else {
106                    FactLoadResult::Found(self.relationships.contains(key))
107                }
108            })
109            .collect()
110    }
111}
112
113#[tokio::main]
114async fn main() {
115    println!("=== ReBAC Policy Example ===\n");
116
117    let owner = User {
118        id: Uuid::new_v4(),
119        name: "Alice",
120    };
121    let contributor = User {
122        id: Uuid::new_v4(),
123        name: "Bob",
124    };
125    let viewer = User {
126        id: Uuid::new_v4(),
127        name: "Charlie",
128    };
129    let outsider = User {
130        id: Uuid::new_v4(),
131        name: "Dave",
132    };
133    let project = Project {
134        id: Uuid::new_v4(),
135        name: "Sample Project",
136    };
137
138    let relationships = HashSet::from([
139        ProjectRelationship {
140            subject_id: owner.id,
141            resource_id: project.id,
142            relation: Relation::Owner,
143        },
144        ProjectRelationship {
145            subject_id: contributor.id,
146            resource_id: project.id,
147            relation: Relation::Contributor,
148        },
149        ProjectRelationship {
150            subject_id: viewer.id,
151            resource_id: project.id,
152            relation: Relation::Viewer,
153        },
154    ]);
155
156    let registry = FactRegistry::builder()
157        .with::<ProjectRelationship, _>(ProjectRelationshipSource::new(relationships.clone()))
158        .build();
159    let session = registry.session();
160
161    // Editing requires an owner OR contributor relationship; a viewer
162    // relationship exists in the store but grants nothing here.
163    let mut checker = PermissionChecker::<ProjectDomain>::new();
164    checker.add_policy(RebacPolicy::<ProjectDomain, Uuid, Uuid, Relation>::new(
165        |user: &User| user.id,
166        |project: &Project| project.id,
167        Relation::Owner,
168    ));
169    checker.add_policy(RebacPolicy::<ProjectDomain, Uuid, Uuid, Relation>::new(
170        |user: &User| user.id,
171        |project: &Project| project.id,
172        Relation::Contributor,
173    ));
174
175    // (user, relationship held, expected outcome)
176    let cases = [
177        (&owner, "owner", true),
178        (&contributor, "contributor", true),
179        (&viewer, "viewer", false),
180        (&outsider, "none", false),
181    ];
182    for (user, held, expected_granted) in cases {
183        println!("Can {} ({held}) edit {}?", user.name, project.name);
184        let decision = checker
185            .bind(&session, user, &EditAction, &())
186            .check(&project)
187            .await;
188        println!(
189            "  -> {}\n",
190            if decision.is_granted() {
191                "GRANTED"
192            } else {
193                "DENIED"
194            }
195        );
196        assert_eq!(decision.is_granted(), expected_granted);
197    }
198
199    // The trace records the facts each policy consulted (the `↳ fact` lines)
200    // alongside its decision — here the viewer's denial shows both
201    // relationship lookups coming back false. Note that no new "loading fact"
202    // lines appear: this re-check runs in the same session, so the facts come
203    // from the session cache.
204    println!("Why {} is denied:", viewer.name);
205    let decision = checker
206        .bind(&session, &viewer, &EditAction, &())
207        .check(&project)
208        .await;
209    println!("{}\n", decision.display_trace());
210
211    println!("=== Error During Relationship Loading ===\n");
212
213    // A failing store must never grant: the load error is carried into the
214    // trace and the decision fails closed to denial — even for the owner.
215    let error_registry = FactRegistry::builder()
216        .with::<ProjectRelationship, _>(ProjectRelationshipSource::new(relationships).with_error())
217        .build();
218    let error_session = error_registry.session();
219    let decision = checker
220        .bind(&error_session, &owner, &EditAction, &())
221        .check(&project)
222        .await;
223    println!("{}", decision.display_trace());
224    decision.assert_denied();
225    decision.assert_trace_contains("simulated relationship store error");
226}