Skip to main content

larust_auth/
policy.rs

1use crate::Authenticatable;
2use larust_core::AppError;
3
4/// A per-resource authorization policy (Laravel's Policy classes), hand-
5/// implemented once per model - the same shape as [`crate::Authenticatable`]
6/// on `User`: a small trait the app implements directly, not macro-generated
7/// or auto-discovered, so a typo in an ability name is a compile error
8/// rather than a silently-always-false runtime lookup.
9///
10/// `view_any`/`create` are class-level abilities (no specific row exists
11/// yet - Laravel checks these against `Post::class`, not an instance) and
12/// take no `&self`; `view`/`update`/`delete` are instance-level and do.
13/// Rust traits allow mixing associated functions and methods freely, so
14/// this stays one trait rather than two.
15///
16/// Deliberately excludes Laravel's `restore`/`forceDelete` - this
17/// framework has no soft-delete concept anywhere in `larust-orm`, so those
18/// two abilities would have nothing to gate.
19///
20/// All 5 abilities are required, with no default `false` body: a trait-
21/// level default would reintroduce exactly the "silent gap instead of
22/// compile error" failure mode [`crate::authorize`]'s own doc comment
23/// already treats as this framework's core selling point over Laravel's
24/// `Gate::define`. Forgetting to decide an ability is a compile error, not
25/// a silently-safe (or silently-unsafe) default.
26///
27/// Each ability has a matching `authorize_*` default method that converts
28/// its bool into a `Result<(), AppError>` via [`crate::authorize`] - sugar
29/// so a caller writes `post.authorize_update(&user)?` instead of
30/// `authorize(post.update(&user))?`.
31///
32/// ```ignore
33/// impl larust_support::auth::Policy<User> for Post {
34///     fn view_any(_user: &User) -> bool { true }
35///     fn view(&self, _user: &User) -> bool { true }
36///     fn create(_user: &User) -> bool { true }
37///     fn update(&self, user: &User) -> bool { self.user_id == user.id }
38///     fn delete(&self, user: &User) -> bool { self.user_id == user.id }
39/// }
40/// ```
41pub trait Policy<U: Authenticatable> {
42    /// Whether `user` may list resources of this type (Laravel's `viewAny`).
43    fn view_any(user: &U) -> bool;
44
45    /// Whether `user` may view this specific resource (Laravel's `view`).
46    fn view(&self, user: &U) -> bool;
47
48    /// Whether `user` may create a resource of this type (Laravel's `create`).
49    fn create(user: &U) -> bool;
50
51    /// Whether `user` may update this specific resource (Laravel's `update`).
52    fn update(&self, user: &U) -> bool;
53
54    /// Whether `user` may delete this specific resource (Laravel's `delete`).
55    fn delete(&self, user: &U) -> bool;
56
57    /// [`Policy::view_any`], converted straight to a 403 on failure.
58    fn authorize_view_any(user: &U) -> Result<(), AppError> {
59        crate::authorize(Self::view_any(user))
60    }
61
62    /// [`Policy::view`], converted straight to a 403 on failure.
63    fn authorize_view(&self, user: &U) -> Result<(), AppError> {
64        crate::authorize(self.view(user))
65    }
66
67    /// [`Policy::create`], converted straight to a 403 on failure.
68    fn authorize_create(user: &U) -> Result<(), AppError> {
69        crate::authorize(Self::create(user))
70    }
71
72    /// [`Policy::update`], converted straight to a 403 on failure.
73    fn authorize_update(&self, user: &U) -> Result<(), AppError> {
74        crate::authorize(self.update(user))
75    }
76
77    /// [`Policy::delete`], converted straight to a 403 on failure.
78    fn authorize_delete(&self, user: &U) -> Result<(), AppError> {
79        crate::authorize(self.delete(user))
80    }
81}
82
83#[cfg(test)]
84mod tests {
85    use super::*;
86    use axum::http::StatusCode;
87
88    struct TestUser {
89        id: i64,
90    }
91
92    impl Authenticatable for TestUser {
93        fn auth_id(&self) -> i64 {
94            self.id
95        }
96
97        async fn find_for_auth(_id: i64) -> Result<Option<Self>, AppError> {
98            unimplemented!("not exercised by these pure-logic tests")
99        }
100    }
101
102    struct TestResource {
103        owner_id: i64,
104    }
105
106    impl Policy<TestUser> for TestResource {
107        fn view_any(_user: &TestUser) -> bool {
108            true
109        }
110
111        fn view(&self, _user: &TestUser) -> bool {
112            true
113        }
114
115        fn create(_user: &TestUser) -> bool {
116            true
117        }
118
119        fn update(&self, user: &TestUser) -> bool {
120            self.owner_id == user.id
121        }
122
123        fn delete(&self, user: &TestUser) -> bool {
124            self.owner_id == user.id
125        }
126    }
127
128    fn assert_forbidden(result: Result<(), AppError>) {
129        let err = result.unwrap_err();
130        assert!(matches!(
131            err,
132            AppError::Http {
133                status: StatusCode::FORBIDDEN,
134                ..
135            }
136        ));
137    }
138
139    #[test]
140    fn class_level_abilities_are_callable_as_associated_functions() {
141        let user = TestUser { id: 1 };
142        assert!(TestResource::view_any(&user));
143        assert!(TestResource::create(&user));
144        assert!(TestResource::authorize_view_any(&user).is_ok());
145        assert!(TestResource::authorize_create(&user).is_ok());
146    }
147
148    #[test]
149    fn instance_level_abilities_are_callable_as_methods() {
150        let owner = TestUser { id: 1 };
151        let stranger = TestUser { id: 2 };
152        let resource = TestResource { owner_id: 1 };
153
154        assert!(resource.view(&owner));
155        assert!(resource.update(&owner));
156        assert!(!resource.update(&stranger));
157        assert!(resource.delete(&owner));
158        assert!(!resource.delete(&stranger));
159    }
160
161    #[test]
162    fn authorize_update_allows_the_owner() {
163        let owner = TestUser { id: 1 };
164        let resource = TestResource { owner_id: 1 };
165        assert!(resource.authorize_update(&owner).is_ok());
166    }
167
168    #[test]
169    fn authorize_update_forbids_a_non_owner() {
170        let stranger = TestUser { id: 2 };
171        let resource = TestResource { owner_id: 1 };
172        assert_forbidden(resource.authorize_update(&stranger));
173    }
174
175    #[test]
176    fn authorize_delete_forbids_a_non_owner() {
177        let stranger = TestUser { id: 2 };
178        let resource = TestResource { owner_id: 1 };
179        assert_forbidden(resource.authorize_delete(&stranger));
180    }
181}