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}