Skip to main content

ferro_rs/authorization/
policy.rs

1//! Policy trait for model-based authorization.
2//!
3//! Policies organize authorization logic around a particular model or resource.
4
5use super::response::AuthResponse;
6use crate::auth::Authenticatable;
7
8/// Trait for authorization policies.
9///
10/// Policies organize authorization logic around a particular model.
11/// Each method corresponds to a specific ability (view, create, update, delete, etc.).
12///
13/// # Example
14///
15/// ```rust,ignore
16/// use ferro_rs::authorization::{Policy, AuthResponse};
17/// use crate::models::{User, Post};
18///
19/// pub struct PostPolicy;
20///
21/// impl Policy<Post> for PostPolicy {
22///     type User = User;
23///
24///     fn before(&self, user: &Self::User, _ability: &str) -> Option<bool> {
25///         // Admins can do everything
26///         if user.is_admin {
27///             return Some(true);
28///         }
29///         None // Continue to specific check
30///     }
31///
32///     fn view(&self, _user: &Self::User, _model: &Post) -> AuthResponse {
33///         AuthResponse::allow() // Anyone can view posts
34///     }
35///
36///     fn update(&self, user: &Self::User, post: &Post) -> AuthResponse {
37///         if user.id == post.user_id {
38///             AuthResponse::allow()
39///         } else {
40///             AuthResponse::deny("You do not own this post.")
41///         }
42///     }
43///
44///     fn delete(&self, user: &Self::User, post: &Post) -> AuthResponse {
45///         self.update(user, post) // Same as update
46///     }
47/// }
48/// ```
49pub trait Policy<M>: Send + Sync {
50    /// The user type for this policy.
51    type User: Authenticatable;
52
53    /// Run before any other authorization checks.
54    ///
55    /// Return `Some(true)` to allow, `Some(false)` to deny,
56    /// or `None` to continue to the specific ability check.
57    ///
58    /// This is useful for implementing admin bypass.
59    fn before(&self, _user: &Self::User, _ability: &str) -> Option<bool> {
60        None
61    }
62
63    /// Determine whether the user can view any models.
64    fn view_any(&self, _user: &Self::User) -> AuthResponse {
65        AuthResponse::deny_silent()
66    }
67
68    /// Determine whether the user can view the model.
69    fn view(&self, _user: &Self::User, _model: &M) -> AuthResponse {
70        AuthResponse::deny_silent()
71    }
72
73    /// Determine whether the user can create models.
74    fn create(&self, _user: &Self::User) -> AuthResponse {
75        AuthResponse::deny_silent()
76    }
77
78    /// Determine whether the user can update the model.
79    fn update(&self, _user: &Self::User, _model: &M) -> AuthResponse {
80        AuthResponse::deny_silent()
81    }
82
83    /// Determine whether the user can delete the model.
84    fn delete(&self, _user: &Self::User, _model: &M) -> AuthResponse {
85        AuthResponse::deny_silent()
86    }
87
88    /// Determine whether the user can restore the model.
89    fn restore(&self, _user: &Self::User, _model: &M) -> AuthResponse {
90        AuthResponse::deny_silent()
91    }
92
93    /// Determine whether the user can permanently delete the model.
94    fn force_delete(&self, _user: &Self::User, _model: &M) -> AuthResponse {
95        AuthResponse::deny_silent()
96    }
97
98    /// Check an ability with the before hook applied.
99    ///
100    /// This method handles the `before` hook automatically.
101    fn check(&self, user: &Self::User, ability: &str, model: Option<&M>) -> AuthResponse {
102        // Check before hook first
103        if let Some(result) = self.before(user, ability) {
104            return result.into();
105        }
106
107        // Run the specific ability check
108        match ability {
109            "viewAny" | "view_any" => self.view_any(user),
110            "view" => model
111                .map(|m| self.view(user, m))
112                .unwrap_or_else(AuthResponse::deny_silent),
113            "create" => self.create(user),
114            "update" => model
115                .map(|m| self.update(user, m))
116                .unwrap_or_else(AuthResponse::deny_silent),
117            "delete" => model
118                .map(|m| self.delete(user, m))
119                .unwrap_or_else(AuthResponse::deny_silent),
120            "restore" => model
121                .map(|m| self.restore(user, m))
122                .unwrap_or_else(AuthResponse::deny_silent),
123            "forceDelete" | "force_delete" => model
124                .map(|m| self.force_delete(user, m))
125                .unwrap_or_else(AuthResponse::deny_silent),
126            _ => AuthResponse::deny_silent(),
127        }
128    }
129}
130
131#[cfg(test)]
132mod tests {
133    use super::*;
134    use std::any::Any;
135
136    // Test user
137    #[derive(Debug, Clone)]
138    struct TestUser {
139        id: i64,
140        is_admin: bool,
141    }
142
143    impl Authenticatable for TestUser {
144        fn auth_identifier(&self) -> i64 {
145            self.id
146        }
147
148        fn as_any(&self) -> &dyn Any {
149            self
150        }
151    }
152
153    // Test model
154    #[derive(Debug)]
155    struct TestPost {
156        #[allow(dead_code)]
157        id: i64,
158        user_id: i64,
159    }
160
161    // Test policy
162    struct TestPostPolicy;
163
164    impl Policy<TestPost> for TestPostPolicy {
165        type User = TestUser;
166
167        fn before(&self, user: &Self::User, _ability: &str) -> Option<bool> {
168            if user.is_admin {
169                return Some(true);
170            }
171            None
172        }
173
174        fn view(&self, _user: &Self::User, _model: &TestPost) -> AuthResponse {
175            AuthResponse::allow()
176        }
177
178        fn update(&self, user: &Self::User, post: &TestPost) -> AuthResponse {
179            (user.id == post.user_id).into()
180        }
181
182        fn delete(&self, user: &Self::User, post: &TestPost) -> AuthResponse {
183            self.update(user, post)
184        }
185    }
186
187    #[test]
188    fn test_policy_allows_view() {
189        let policy = TestPostPolicy;
190        let user = TestUser {
191            id: 1,
192            is_admin: false,
193        };
194        let post = TestPost { id: 1, user_id: 2 };
195
196        let response = policy.view(&user, &post);
197        assert!(response.allowed());
198    }
199
200    #[test]
201    fn test_policy_owner_can_update() {
202        let policy = TestPostPolicy;
203        let user = TestUser {
204            id: 1,
205            is_admin: false,
206        };
207        let post = TestPost { id: 1, user_id: 1 };
208
209        let response = policy.update(&user, &post);
210        assert!(response.allowed());
211    }
212
213    #[test]
214    fn test_policy_non_owner_cannot_update() {
215        let policy = TestPostPolicy;
216        let user = TestUser {
217            id: 1,
218            is_admin: false,
219        };
220        let post = TestPost { id: 1, user_id: 2 };
221
222        let response = policy.update(&user, &post);
223        assert!(response.denied());
224    }
225
226    #[test]
227    fn test_policy_admin_bypass() {
228        let policy = TestPostPolicy;
229        let admin = TestUser {
230            id: 1,
231            is_admin: true,
232        };
233        let post = TestPost {
234            id: 1,
235            user_id: 999,
236        };
237
238        // Admin can update any post due to before() hook
239        let response = policy.check(&admin, "update", Some(&post));
240        assert!(response.allowed());
241    }
242
243    #[test]
244    fn test_policy_check_method() {
245        let policy = TestPostPolicy;
246        let user = TestUser {
247            id: 1,
248            is_admin: false,
249        };
250        let post = TestPost { id: 1, user_id: 1 };
251
252        let response = policy.check(&user, "update", Some(&post));
253        assert!(response.allowed());
254
255        let other_post = TestPost { id: 2, user_id: 2 };
256        let response = policy.check(&user, "update", Some(&other_post));
257        assert!(response.denied());
258    }
259}