Unify middleware & extension into extractor #97
Replies: 7 comments 3 replies
-
Thank you for this wonderful and detailed write up! I am very much in favor of this and agree with your assessment. |
Beta Was this translation helpful? Give feedback.
-
Another detail I'm curious about here: how should user stores be handled? |
Beta Was this translation helpful? Give feedback.
-
Update: Implementing role checking has proven troublesome. I said the extractor could be made more ergonomic by moving the
The role can't be an associated type, so even if it were turned into a generic parameter of the user type, it would still have to appear in the extractor. Well, fine. The next problem is that I couldn't figure out how to specify that the extractor's Then let's use dynamic dispatch. But there's still an issue: the priority method was defined as So long as we define the roles with a macro, this should work, but I'm still unhappy with it. I'll sleep on it and see if there's a better implementation (or maybe a better API). Input is welcome. Here's the current implementation: use std::marker::PhantomData;
pub trait Role {
fn priority(&self) -> u8;
}
pub trait ObjectUnsafeRole {
fn priority() -> u8;
}
pub trait Authorize {
fn role(&self) -> &dyn Role;
}
/// This is a convenience trait to simulate getting the user from the request's extensions.
#[cfg_attr(test, mockall::automock)]
pub trait Extensions {
/// mockall requires the `'static` lifetime.
fn get<T: 'static>(&self) -> T;
}
pub trait FromRequestParts {
fn from_request_parts(parts: impl Extensions) -> Self;
}
pub struct Extractor<T, Role>(T, PhantomData<Role>);
impl<User, R> FromRequestParts for Extractor<User, R>
where
User: Authorize + 'static,
R: ObjectUnsafeRole,
{
fn from_request_parts(parts: impl Extensions) -> Self {
let user = parts.get::<User>();
if user.role().priority() >= R::priority() {
Self(user, PhantomData)
} else {
panic!("the real thing would return an error instead");
}
}
}
#[cfg(test)]
mod tests {
use crate::{Authorize, Extractor, FromRequestParts, MockExtensions, ObjectUnsafeRole, Role};
struct Regular;
impl Role for Regular {
fn priority(&self) -> u8 {
1
}
}
impl ObjectUnsafeRole for Regular {
fn priority() -> u8 {
1
}
}
struct Admin;
impl Role for Admin {
fn priority(&self) -> u8 {
2
}
}
impl ObjectUnsafeRole for Admin {
fn priority() -> u8 {
2
}
}
struct User {
role: Box<dyn Role>,
}
impl Authorize for User {
fn role(&self) -> &dyn Role {
self.role.as_ref()
}
}
#[test]
fn regular_allowed_in_regular() {
let mut parts = MockExtensions::new();
parts.expect_get().returning(|| User {
role: Box::new(Regular),
});
let _: Extractor<User, Regular> = Extractor::from_request_parts(parts);
}
#[test]
#[should_panic]
fn regular_not_allowed_in_admin() {
let mut parts = MockExtensions::new();
parts.expect_get().returning(|| User {
role: Box::new(Regular),
});
let _: Extractor<User, Admin> = Extractor::from_request_parts(parts);
}
#[test]
fn admin_allowed_in_regular() {
let mut parts = MockExtensions::new();
parts.expect_get().returning(|| User {
role: Box::new(Admin),
});
let _: Extractor<User, Regular> = Extractor::from_request_parts(parts);
}
#[test]
fn admin_allowed_in_admin() {
let mut parts = MockExtensions::new();
parts.expect_get().returning(|| User {
role: Box::new(Admin),
});
let _: Extractor<User, Admin> = Extractor::from_request_parts(parts);
}
} |
Beta Was this translation helpful? Give feedback.
-
After some thought, I believe I've figured out what shape the API can take. The role type cannot be moved into the user type. The simplest possible form for an extractor is Because we know the type of the lowest possible role that may hit a route but don't have an instance of it, the priority function must be defined as The two priority function must return the same value for the same type, but while hacky, a macro will take care of this. The traits will have to be public since they need to be implemented for the consumer's types, but the macro will hide them. We can even use the Regarding
|
Beta Was this translation helpful? Give feedback.
-
@dsaghliani maxcountryman/tower-sessions#2 might be relevant here, specifically this idea of "buckets". |
Beta Was this translation helpful? Give feedback.
-
Here's an extractor-only approach: #100 (comment) |
Beta Was this translation helpful? Give feedback.
-
@dsaghliani we've redesigned the crate around tower-sessions--let me know what you think about this given that new design. |
Beta Was this translation helpful? Give feedback.
-
In most, if not all, cases where you protect a route, you also pass it the user object. Currently, that's split into two parts:
Extension
.The use of the extension system introduces the possibility of runtime panics, and while that's by no means a dealbreaker, I think there's an opportunity for improvement by unifying the two concepts into an extractor.
Possible Implementation
This could potentially be made more ergonomic by moving the role into the consumer's user type.
Rejection Behavior
By default, the extractor will return
401: Unauthorized
just as the middleware currently does. A redirect URL can't be included in the type signature (nor should it), but that can be solved easily enough.Solution 1: Rely On axum-extra's
WithRejection
This is perhaps the purest solution, but I'm not a fan because it would have to appear in every route handler and really bloat the code.
Solution 2: Include A
LoginRedirect
ExtensionConsumers of axum-login can add an extension to the router with the redirection information. (This can be extended to a completely custom rejection, if necessary.) The extractor can then try to grab that and use it if it exists or fall back to the default behavior.
Concerns
Protected Routes That Don't Need The User
To be honest, I can't think of any cases where you'd want to protect a route but not need any user information. Still, there are bound to be some. This entire proposal is predicated on the assumption that these cases are rare.
The occasional exception can be solved simply by requesting but not using the user object (
_: User<MyUser>
), but if this is more common than expected, it might make sense to leave the authorization middleware so it can cover a swathe of routes.What About Role Ordering?
The thorniest part of this proposal is that by breaking up roles from an enum into multiple structs, we can no longer use
PartialOrd
. This can be mitigated by introducing a trait for priority ranking that would simply return a number. Of course, implementing this trait by hand is error-prone and absolutely a no-go, but that can be solved by a macro.In appearance and effect, this is equivalent to the
PartialOrd
enum implementation, but there is certainly an appeal in keeping things macro-free. It's more elegant. However, I think a macro as small and simple as this is worth the elegance and expressiveness of an extractor implementation.What do you folks think?
Beta Was this translation helpful? Give feedback.
All reactions