ferro-rs 0.2.8

A Laravel-inspired web framework for Rust
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
//! Authorization Gate facade.
//!
//! Provides Laravel-like authorization checking.

use super::error::AuthorizationError;
use super::response::AuthResponse;
use crate::auth::Authenticatable;
use std::any::{Any, TypeId};
use std::collections::HashMap;
use std::sync::{Arc, RwLock};

/// Type alias for gate ability callbacks.
type AbilityCallback =
    Box<dyn Fn(&dyn Authenticatable, Option<&dyn Any>) -> AuthResponse + Send + Sync>;

/// Type alias for before/after callbacks.
type BeforeCallback = Box<dyn Fn(&dyn Authenticatable, &str) -> Option<bool> + Send + Sync>;

/// Global gate registry.
static GATE_REGISTRY: RwLock<Option<GateRegistry>> = RwLock::new(None);

/// Internal registry for gates and policies.
struct GateRegistry {
    /// Simple ability callbacks.
    abilities: HashMap<String, AbilityCallback>,
    /// Before hooks (run before any ability check).
    before_hooks: Vec<BeforeCallback>,
    /// Policy type mappings (model TypeId -> policy factory).
    policies: HashMap<TypeId, Arc<dyn Any + Send + Sync>>,
}

impl GateRegistry {
    fn new() -> Self {
        Self {
            abilities: HashMap::new(),
            before_hooks: Vec::new(),
            policies: HashMap::new(),
        }
    }
}

/// Authorization Gate facade.
///
/// Provides a central point for authorization checks.
///
/// # Example
///
/// ```rust,ignore
/// use ferro_rs::authorization::Gate;
///
/// // Define a simple gate
/// Gate::define("admin", |user, _| user.is_admin().into());
///
/// // Check in controller
/// if Gate::allows("admin", None) {
///     // User is admin
/// }
///
/// // Authorize (returns Result)
/// Gate::authorize("admin", None)?;
/// ```
pub struct Gate;

impl Gate {
    /// Initialize the gate registry.
    ///
    /// This is called automatically by the framework during bootstrap.
    pub fn init() {
        let mut registry = GATE_REGISTRY.write().unwrap();
        if registry.is_none() {
            *registry = Some(GateRegistry::new());
        }
    }

    /// Define a simple ability.
    ///
    /// # Example
    ///
    /// ```rust,ignore
    /// Gate::define("view-dashboard", |user, _| {
    ///     user.as_any().downcast_ref::<User>()
    ///         .map(|u| (u.is_admin || u.has_role("manager")).into())
    ///         .unwrap_or_else(AuthResponse::deny_silent)
    /// });
    /// ```
    pub fn define<F>(ability: &str, callback: F)
    where
        F: Fn(&dyn Authenticatable, Option<&dyn Any>) -> AuthResponse + Send + Sync + 'static,
    {
        Self::init();
        let mut registry = GATE_REGISTRY.write().unwrap();
        if let Some(ref mut reg) = *registry {
            reg.abilities
                .insert(ability.to_string(), Box::new(callback));
        }
    }

    /// Register a before hook.
    ///
    /// Before hooks run before any ability check. Return `Some(true)` to allow,
    /// `Some(false)` to deny, or `None` to continue to the ability check.
    ///
    /// # Example
    ///
    /// ```rust,ignore
    /// // Allow super admins to bypass all checks
    /// Gate::before(|user, _ability| {
    ///     if let Some(u) = user.as_any().downcast_ref::<User>() {
    ///         if u.is_super_admin {
    ///             return Some(true);
    ///         }
    ///     }
    ///     None
    /// });
    /// ```
    pub fn before<F>(callback: F)
    where
        F: Fn(&dyn Authenticatable, &str) -> Option<bool> + Send + Sync + 'static,
    {
        Self::init();
        let mut registry = GATE_REGISTRY.write().unwrap();
        if let Some(ref mut reg) = *registry {
            reg.before_hooks.push(Box::new(callback));
        }
    }

    /// Check if the current user is allowed to perform an ability.
    ///
    /// Returns `true` if allowed, `false` if denied or not authenticated.
    pub fn allows(ability: &str, resource: Option<&dyn Any>) -> bool {
        crate::auth::Auth::id().is_some() && Self::allows_for_user_id(ability, resource)
    }

    /// Check if the current user is denied an ability.
    pub fn denies(ability: &str, resource: Option<&dyn Any>) -> bool {
        !Self::allows(ability, resource)
    }

    /// Authorize the current user for an ability.
    ///
    /// Returns `Ok(())` if allowed, or `Err(AuthorizationError)` if denied.
    ///
    /// # Example
    ///
    /// ```rust,ignore
    /// pub async fn admin_dashboard() -> Result<Response, AuthorizationError> {
    ///     Gate::authorize("view-dashboard", None)?;
    ///     // Render dashboard...
    /// }
    /// ```
    pub fn authorize(ability: &str, resource: Option<&dyn Any>) -> Result<(), AuthorizationError> {
        if crate::auth::Auth::id().is_none() {
            return Err(AuthorizationError::new(ability).with_status(401));
        }

        if Self::allows_for_user_id(ability, resource) {
            Ok(())
        } else {
            Err(AuthorizationError::new(ability))
        }
    }

    /// Check ability for a specific user.
    pub fn allows_for<U: Authenticatable>(
        user: &U,
        ability: &str,
        resource: Option<&dyn Any>,
    ) -> bool {
        Self::inspect(user, ability, resource).allowed()
    }

    /// Authorize for a specific user.
    pub fn authorize_for<U: Authenticatable>(
        user: &U,
        ability: &str,
        resource: Option<&dyn Any>,
    ) -> Result<(), AuthorizationError> {
        let response = Self::inspect(user, ability, resource);
        if response.allowed() {
            Ok(())
        } else {
            let mut error = AuthorizationError::new(ability);
            if let Some(msg) = response.message() {
                error.message = Some(msg.to_string());
            }
            error.status = response.status();
            Err(error)
        }
    }

    /// Check ability for a specific user (generic wrapper).
    pub fn check_for<U: Authenticatable>(
        user: &U,
        ability: &str,
        resource: Option<&dyn Any>,
    ) -> AuthResponse {
        Self::inspect(user, ability, resource)
    }

    /// Check ability for a dynamic Authenticatable reference.
    ///
    /// Use this when you have a trait object (`&dyn Authenticatable` or `Arc<dyn Authenticatable>`).
    pub fn inspect(
        user: &dyn Authenticatable,
        ability: &str,
        resource: Option<&dyn Any>,
    ) -> AuthResponse {
        let registry = GATE_REGISTRY.read().unwrap();
        let reg = match &*registry {
            Some(r) => r,
            None => return AuthResponse::deny_silent(),
        };

        // Run before hooks
        for hook in &reg.before_hooks {
            if let Some(result) = hook(user, ability) {
                return result.into();
            }
        }

        // Check ability callback
        if let Some(callback) = reg.abilities.get(ability) {
            return callback(user, resource);
        }

        // No matching ability found
        AuthResponse::deny_silent()
    }

    /// Internal: check using current user ID.
    fn allows_for_user_id(ability: &str, _resource: Option<&dyn Any>) -> bool {
        // We can't easily get the full user here without async,
        // so we check against stored abilities that work with Authenticatable
        let registry = GATE_REGISTRY.read().unwrap();
        let reg = match &*registry {
            Some(r) => r,
            None => return false,
        };

        // If there's no ability defined, deny
        if !reg.abilities.contains_key(ability) && reg.before_hooks.is_empty() {
            return false;
        }

        // For now, return false if we can't resolve the user synchronously
        // The async version should be used when user data is needed
        false
    }

    /// Check if a policy is registered for a model type.
    pub fn has_policy_for<M: 'static>() -> bool {
        let registry = GATE_REGISTRY.read().unwrap();
        registry
            .as_ref()
            .map(|r| r.policies.contains_key(&TypeId::of::<M>()))
            .unwrap_or(false)
    }

    /// Clear all registered gates (useful for testing).
    #[cfg(test)]
    pub fn flush() {
        let mut registry = GATE_REGISTRY.write().unwrap();
        *registry = Some(GateRegistry::new());
    }

    /// Test lock for serializing tests that use the global gate registry.
    #[cfg(test)]
    pub fn test_lock() -> std::sync::MutexGuard<'static, ()> {
        use std::sync::Mutex;
        static TEST_LOCK: Mutex<()> = Mutex::new(());
        TEST_LOCK.lock().unwrap()
    }
}

/// Extension methods for checking authorization with the current user.
///
/// These are async methods that fetch the user before checking.
impl Gate {
    /// Check if the current authenticated user is allowed (async).
    ///
    /// This fetches the user from the database before checking.
    pub async fn user_allows(ability: &str, resource: Option<&dyn Any>) -> bool {
        match Self::resolve_user_and_check(ability, resource).await {
            Ok(response) => response.allowed(),
            Err(_) => false,
        }
    }

    /// Authorize the current authenticated user (async).
    ///
    /// This fetches the user from the database before checking.
    pub async fn user_authorize(
        ability: &str,
        resource: Option<&dyn Any>,
    ) -> Result<(), AuthorizationError> {
        let response = Self::resolve_user_and_check(ability, resource).await?;
        if response.allowed() {
            Ok(())
        } else {
            let mut error = AuthorizationError::new(ability);
            if let Some(msg) = response.message() {
                error.message = Some(msg.to_string());
            }
            error.status = response.status();
            Err(error)
        }
    }

    /// Internal: resolve user and check ability.
    async fn resolve_user_and_check(
        ability: &str,
        resource: Option<&dyn Any>,
    ) -> Result<AuthResponse, AuthorizationError> {
        let user = crate::auth::Auth::user()
            .await
            .map_err(|_| AuthorizationError::new(ability).with_status(401))?
            .ok_or_else(|| AuthorizationError::new(ability).with_status(401))?;

        Ok(Self::inspect(user.as_ref(), ability, resource))
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::any::Any;

    #[derive(Debug, Clone)]
    struct TestUser {
        id: i64,
        is_admin: bool,
    }

    impl Authenticatable for TestUser {
        fn auth_identifier(&self) -> i64 {
            self.id
        }

        fn as_any(&self) -> &dyn Any {
            self
        }
    }

    #[test]
    fn test_define_and_check() {
        let _guard = Gate::test_lock();
        Gate::flush();

        Gate::define("test-ability", |user, _| {
            user.as_any()
                .downcast_ref::<TestUser>()
                .map(|u| u.is_admin.into())
                .unwrap_or_else(AuthResponse::deny_silent)
        });

        let admin = TestUser {
            id: 1,
            is_admin: true,
        };
        let regular = TestUser {
            id: 2,
            is_admin: false,
        };

        assert!(Gate::allows_for(&admin, "test-ability", None));
        assert!(!Gate::allows_for(&regular, "test-ability", None));
    }

    #[test]
    fn test_before_hook() {
        let _guard = Gate::test_lock();
        Gate::flush();

        Gate::before(|user, _| {
            if let Some(u) = user.as_any().downcast_ref::<TestUser>() {
                if u.is_admin {
                    return Some(true);
                }
            }
            None
        });

        // Define an ability that always denies
        Gate::define("restricted", |_, _| AuthResponse::deny("Always denied"));

        let admin = TestUser {
            id: 1,
            is_admin: true,
        };
        let regular = TestUser {
            id: 2,
            is_admin: false,
        };

        // Admin bypasses via before hook
        assert!(Gate::allows_for(&admin, "restricted", None));
        // Regular user is denied
        assert!(!Gate::allows_for(&regular, "restricted", None));
    }

    #[test]
    fn test_authorize_for() {
        let _guard = Gate::test_lock();
        Gate::flush();

        Gate::define("view-posts", |_, _| AuthResponse::allow());
        Gate::define("admin-only", |user, _| {
            user.as_any()
                .downcast_ref::<TestUser>()
                .map(|u| {
                    if u.is_admin {
                        AuthResponse::allow()
                    } else {
                        AuthResponse::deny("Admin access required")
                    }
                })
                .unwrap_or_else(AuthResponse::deny_silent)
        });

        let admin = TestUser {
            id: 1,
            is_admin: true,
        };
        let regular = TestUser {
            id: 2,
            is_admin: false,
        };

        // Should succeed
        assert!(Gate::authorize_for(&admin, "view-posts", None).is_ok());
        assert!(Gate::authorize_for(&regular, "view-posts", None).is_ok());
        assert!(Gate::authorize_for(&admin, "admin-only", None).is_ok());

        // Should fail
        let err = Gate::authorize_for(&regular, "admin-only", None).unwrap_err();
        assert_eq!(err.message, Some("Admin access required".to_string()));
    }

    #[test]
    fn test_undefined_ability() {
        let _guard = Gate::test_lock();
        Gate::flush();

        let user = TestUser {
            id: 1,
            is_admin: false,
        };

        // Undefined abilities should deny
        assert!(!Gate::allows_for(&user, "undefined-ability", None));
    }
}