diff --git a/examples/rebac_policy.rs b/examples/rebac_policy.rs index ba255d1..a165556 100644 --- a/examples/rebac_policy.rs +++ b/examples/rebac_policy.rs @@ -11,6 +11,7 @@ use async_trait::async_trait; use gatehouse::*; +use std::fmt; use std::time::Duration; use uuid::Uuid; @@ -66,8 +67,13 @@ impl ProjectRelationshipResolver { } #[async_trait] -impl RelationshipResolver for ProjectRelationshipResolver { - async fn has_relationship(&self, user: &User, project: &Project, relationship: &str) -> bool { +impl RelationshipResolver for ProjectRelationshipResolver { + async fn has_relationship( + &self, + user: &User, + project: &Project, + relationship: &String, + ) -> bool { println!( "Checking if user {} has '{}' relationship with project {}", user.name, relationship, project.name @@ -156,18 +162,20 @@ async fn main() { let normal_resolver = ProjectRelationshipResolver::new(relationships.clone()); // Create ReBAC policies for different relationships - let owner_policy = RebacPolicy::::new( - "owner", + let owner_policy = RebacPolicy::::new( + "owner".to_string(), normal_resolver.clone(), ); - let contributor_policy = RebacPolicy::::new( - "contributor", + let contributor_policy = RebacPolicy::::new( + "contributor".to_string(), normal_resolver.clone(), ); - let _viewer_policy = - RebacPolicy::::new("viewer", normal_resolver); + let _viewer_policy = RebacPolicy::::new( + "viewer".to_string(), + normal_resolver, + ); // Create a permission checker with multiple policies // Only owners and contributors can edit, not viewers @@ -186,8 +194,10 @@ async fn main() { // Create a resolver that simulates a database error let error_resolver = ProjectRelationshipResolver::new(relationships.clone()).with_error(); - let error_policy = - RebacPolicy::::new("owner", error_resolver); + let error_policy = RebacPolicy::::new( + "owner".to_string(), + error_resolver, + ); let mut error_checker = PermissionChecker::::new(); error_checker.add_policy(error_policy); @@ -199,14 +209,19 @@ async fn main() { // Create a resolver that simulates a timeout let timeout_resolver = ProjectRelationshipResolver::new(relationships).with_timeout(); - let timeout_policy = - RebacPolicy::::new("owner", timeout_resolver); + let timeout_policy = RebacPolicy::::new( + "owner".to_string(), + timeout_resolver, + ); let mut timeout_checker = PermissionChecker::::new(); timeout_checker.add_policy(timeout_policy); println!("Testing with database timeout:"); test_access(&timeout_checker, &owner, &project).await; + + // Demonstrate enum-based relationships (type-safe alternative to strings) + enum_relationship_example().await; } async fn test_access( @@ -240,3 +255,114 @@ async fn test_access( } ); } + +// --- Enum-based relationship example --- +// +// The relationship type parameter is generic, so you can use enums instead +// of strings for compile-time safety. A typo like "contibutor" becomes a +// compile error rather than a silent permission failure. + +#[derive(Debug, Clone, PartialEq)] +enum Relation { + Owner, + Contributor, + Viewer, +} + +impl fmt::Display for Relation { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Relation::Owner => write!(f, "owner"), + Relation::Contributor => write!(f, "contributor"), + Relation::Viewer => write!(f, "viewer"), + } + } +} + +struct EnumRelationshipResolver { + relationships: Vec<(Uuid, Uuid, Relation)>, +} + +#[async_trait] +impl RelationshipResolver for EnumRelationshipResolver { + async fn has_relationship( + &self, + user: &User, + project: &Project, + relationship: &Relation, + ) -> bool { + self.relationships + .iter() + .any(|(uid, pid, rel)| *uid == user.id && *pid == project.id && rel == relationship) + } +} + +async fn enum_relationship_example() { + println!("\n=== Enum-Based Relationship Types ===\n"); + + let alice = User { + id: Uuid::new_v4(), + name: "Alice".to_string(), + }; + let bob = User { + id: Uuid::new_v4(), + name: "Bob".to_string(), + }; + + let project = Project { + id: Uuid::new_v4(), + name: "Typed Project".to_string(), + }; + + let charlie = User { + id: Uuid::new_v4(), + name: "Charlie".to_string(), + }; + + let resolver = EnumRelationshipResolver { + relationships: vec![ + (alice.id, project.id, Relation::Owner), + (bob.id, project.id, Relation::Contributor), + (charlie.id, project.id, Relation::Viewer), + ], + }; + + // Only owners can edit — using an enum variant instead of a string. + let owner_policy = RebacPolicy::::new( + Relation::Owner, + resolver, + ); + + let mut checker = PermissionChecker::new(); + checker.add_policy(owner_policy); + + let context = EmptyContext; + let action = EditAction; + + for (user, expected_granted, role) in [ + (&alice, true, "owner"), + (&bob, false, "contributor"), + (&charlie, false, "viewer"), + ] { + let result = checker + .evaluate_access(user, &action, &project, &context) + .await; + let status = if result.is_granted() { + "GRANTED ✓" + } else { + "DENIED ✗" + }; + println!( + "{} ({}) edit access: {} (expected: {})", + user.name, + role, + status, + if expected_granted { + "granted" + } else { + "denied" + } + ); + assert_eq!(result.is_granted(), expected_granted); + } +} diff --git a/src/lib.rs b/src/lib.rs index 0a80561..cc2602c 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1291,10 +1291,13 @@ where /// A trait that abstracts a relationship resolver. /// Given a subject and a resource, the resolver answers whether the /// specified relationship e.g. "creator", "manager" exists between them. +/// +/// The relationship type `Re` is generic, so you can use strings, enums, or +/// other domain-specific types. #[async_trait] -pub trait RelationshipResolver: Send + Sync { +pub trait RelationshipResolver: Send + Sync { /// Returns `true` if `relationship` exists between `subject` and `resource`. - async fn has_relationship(&self, subject: &S, resource: &R, relationship: &str) -> bool; + async fn has_relationship(&self, subject: &S, resource: &R, relationship: &Re) -> bool; } /// ### ReBAC Policy @@ -1330,20 +1333,20 @@ pub trait RelationshipResolver: Send + Sync { /// struct DummyRelationshipResolver; /// /// #[async_trait] -/// impl RelationshipResolver for DummyRelationshipResolver { +/// impl RelationshipResolver for DummyRelationshipResolver { /// async fn has_relationship( /// &self, /// employee: &Employee, /// project: &Project, -/// relationship: &str, +/// relationship: &String, /// ) -> bool { /// relationship == "manager" && employee.id == project.manager_id /// } /// } /// /// // Create a ReBAC policy that checks for the "manager" relationship. -/// let rebac_policy = RebacPolicy::::new( -/// "manager", +/// let rebac_policy = RebacPolicy::::new( +/// "manager".to_string(), /// DummyRelationshipResolver, /// ); /// @@ -1368,19 +1371,22 @@ pub trait RelationshipResolver: Send + Sync { /// assert!(!checker.evaluate_access(&other_employee, &AccessAction, &project, &context).await.is_granted()); /// # }); /// ``` -pub struct RebacPolicy { - /// The relationship name to check (e.g. `"manager"`, `"creator"`). - pub relationship: String, +/// +/// The relationship type `Re` must implement [`fmt::Display`] so that policy +/// evaluation reasons can include the relationship value in log messages. +pub struct RebacPolicy { + /// The relationship to check (e.g. `"manager"`, or an enum variant). + pub relationship: Re, /// The resolver that determines whether the relationship exists. pub resolver: RG, _marker: std::marker::PhantomData<(S, R, A, C)>, } -impl RebacPolicy { - /// Create a new RebacPolicy for a given relationship string. - pub fn new(relationship: impl Into, resolver: RG) -> Self { +impl RebacPolicy { + /// Creates a new `RebacPolicy` for a given relationship. + pub fn new(relationship: Re, resolver: RG) -> Self { Self { - relationship: relationship.into(), + relationship, resolver, _marker: std::marker::PhantomData, } @@ -1388,13 +1394,14 @@ impl RebacPolicy { } #[async_trait] -impl Policy for RebacPolicy +impl Policy for RebacPolicy where S: Sync + Send, R: Sync + Send, A: Sync + Send, C: Sync + Send, - RG: RelationshipResolver + Send + Sync, + Re: Sync + Send + fmt::Display, + RG: RelationshipResolver, { async fn evaluate_access( &self, @@ -1878,12 +1885,12 @@ mod tests { } #[async_trait] - impl RelationshipResolver for DummyRelationshipResolver { + impl RelationshipResolver for DummyRelationshipResolver { async fn has_relationship( &self, subject: &TestSubject, resource: &TestResource, - relationship: &str, + relationship: &String, ) -> bool { self.relationships .iter() @@ -1895,19 +1902,16 @@ mod tests { async fn test_rebac_policy_allows_when_relationship_exists() { let subject_id = uuid::Uuid::new_v4(); let resource_id = uuid::Uuid::new_v4(); - let relationship = "manager"; + let relationship = "manager".to_string(); let subject = TestSubject { id: subject_id }; let resource = TestResource { id: resource_id }; // Create a dummy resolver that knows the subject is a manager of the resource. - let resolver = DummyRelationshipResolver::new(vec![( - subject_id, - resource_id, - relationship.to_string(), - )]); + let resolver = + DummyRelationshipResolver::new(vec![(subject_id, resource_id, relationship.clone())]); - let policy = RebacPolicy::::new( + let policy = RebacPolicy::::new( relationship, resolver, ); @@ -1927,7 +1931,7 @@ mod tests { async fn test_rebac_policy_denies_when_relationship_missing() { let subject_id = uuid::Uuid::new_v4(); let resource_id = uuid::Uuid::new_v4(); - let relationship = "manager"; + let relationship = "manager".to_string(); let subject = TestSubject { id: subject_id }; let resource = TestResource { id: resource_id }; @@ -1935,7 +1939,7 @@ mod tests { // Create a dummy resolver with no relationships. let resolver = DummyRelationshipResolver::new(vec![]); - let policy = RebacPolicy::::new( + let policy = RebacPolicy::::new( relationship, resolver, ); @@ -1950,6 +1954,86 @@ mod tests { ); } + // RebacPolicy test with enum relationship type. + + #[derive(Debug, Clone, PartialEq)] + enum TestRelation { + Manager, + Viewer, + } + + impl fmt::Display for TestRelation { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + TestRelation::Manager => write!(f, "manager"), + TestRelation::Viewer => write!(f, "viewer"), + } + } + } + + struct EnumRelationshipResolver { + relationships: Vec<(uuid::Uuid, uuid::Uuid, TestRelation)>, + } + + #[async_trait] + impl RelationshipResolver for EnumRelationshipResolver { + async fn has_relationship( + &self, + subject: &TestSubject, + resource: &TestResource, + relationship: &TestRelation, + ) -> bool { + self.relationships + .iter() + .any(|(s, r, rel)| s == &subject.id && r == &resource.id && rel == relationship) + } + } + + #[tokio::test] + async fn test_rebac_policy_with_enum_relationship() { + let subject_id = uuid::Uuid::new_v4(); + let resource_id = uuid::Uuid::new_v4(); + + let subject = TestSubject { id: subject_id }; + let resource = TestResource { id: resource_id }; + + let resolver = EnumRelationshipResolver { + relationships: vec![(subject_id, resource_id, TestRelation::Manager)], + }; + + let policy = RebacPolicy::::new( + TestRelation::Manager, + resolver, + ); + + // Manager relationship exists — should be granted. + let result = policy + .evaluate_access(&subject, &TestAction, &resource, &TestContext) + .await; + assert!( + result.is_granted(), + "Access should be granted for matching enum relationship" + ); + + // Viewer policy with same resolver (no viewer relationship) — should be denied. + let resolver = EnumRelationshipResolver { + relationships: vec![(subject_id, resource_id, TestRelation::Manager)], + }; + let viewer_policy = + RebacPolicy::::new( + TestRelation::Viewer, + resolver, + ); + + let result = viewer_policy + .evaluate_access(&subject, &TestAction, &resource, &TestContext) + .await; + assert!( + !result.is_granted(), + "Access should be denied when enum relationship does not match" + ); + } + // Combinator tests. #[tokio::test] async fn test_and_policy_allows_when_all_allow() {