-
-
Notifications
You must be signed in to change notification settings - Fork 90
Login required layer builder #285
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Login required layer builder #285
Conversation
Add login_url configuration test
I am leaning towards implementing a proper solution with state, but not sure how to go about it since it requires a lot of changes. |
Is it possible this should be its own crate or a crate that lives within this crate but which can be opted into? These macros are a little bit of an afterthought (provided mostly as a convenience since library consumers are free to design more sophisticated or domain specific solutions using the same parts) so I'm okay to reconsider them more broadly. |
I think moving them into an opt-in crate is definitely possible; similar to how strum separates traits into strum and macro implementations into strum_macros. That said, I believe it's unnecessary in this case and that keeping them as an optional feature within the same crate is okay. I can write some macros variant that support state. I’ve realised this feature is particularly useful for permissions, since those can sometimes be modified by users.
I think having these macros even in their current form significantly lowers the barrier to entry, and is great overall |
Okay great. I'm happy to support further development in this crate. |
This reverts commit 9a2b5ec.
I was thinking about how this should be implemented and I cant really decide. // Approach with path from req
// it maybe redundant if users prefer to get required perms from identifier or just use their app config
login_required_with_state!(
Backend,
state: AppState,
|req: &Request<_>, state: &State| -> Option<RequiredPermissions> {
state.get_required_for_path(req.uri().path())
}
);
// Approach with only state
// This seems to be the minimum
login_required_with_state!(
Backend,
state: AppState,
|state: &State| -> Option<RequiredPermissions> {
state.get_required_for_path("this_path_layer_identifier").path())
}
);
// Approach with req and user
// This one will be very "boilerplaty" If you need so much control you probably shouldn't use these macros
login_required_with_state!(
Backend,
state: AppState,
|req: &Request<_>, state: &State, user: User| -> bool {
let requierd = state.get_required_for_path(req.uri().path());
required.iter().all(|perm| perms.contains(&perm.as_str()));
}
); |
My thought is having the request makes it easier to support more use cases without making it too burdensome. Also as an aside, it seems confusing there are two "states" so something to consider maybe. |
What if we took a different approach entirely and moved away from macros? The existing macros are a convenient wrapper around As a concrete example, we could define a Require middleware like this: pub struct Require<B, P, F> {
backend: B,
predicate: P,
fallback: F,
}
impl<S, B, P, F> tower::Layer<S> for Require<B, P, F>
where
P: Clone,
F: Clone,
{
type Service = RequireService<S, B, P, F>;
fn layer(&self, inner: S) -> Self::Service {
RequireService {
inner,
backend: self.backend.clone(),
predicate: self.predicate.clone(),
fallback: self.fallback.clone(),
}
}
} And pair it with a fluent builder API that enables expressive, declarative configuration: let require_login = Require::login::<MyBackend>()
.login_url("/signin")
.redirect_field("next")
.on_failure(AuthFailure::Unauthorized);
let require_perms = Require::permissions::<MyBackend>()
.permissions(["test.read", "test.write"])
.login_url("/login")
.on_failure(AuthFailure::RedirectDynamic(|req| build_login_uri(req))); This style gives users the benefits of composability and type safety, while offering a cleaner foundation for future enhancements like dynamic state access or more granular fallback behavior. |
I have to agree. I couldn’t find a way to specify only a single State either way, so I’ve been exploring other approaches as well |
…nd permission as a proof of concept Introduces a seems-to-work implementation of a login-required service builder that supports dynamic state and permission evaluation. While the design is still in the works and contains tons of TODOs, it demonstrates that the concept works. The next step would be to add trait variants for simpler variations as in example or maybe improve typing and redundant cloning.
I stumbled into a bit of a problem. I want to remove currently mandatory state(()). There is no way to use () like the default the ordinary way. After thinking about it I couldn't find a good way to do this, other than these: 1. make state and predicate recreate a new struct with different types. This seems to work but is awkwardLooks like this: impl<B: AuthnBackend, T> RequireBuilder<B, (), T> {
pub fn new() -> Self {
Self {
predicate: None,
fallback: None,
login_url: None,
redirect_field: None,
state: None,
}
}
pub fn state<ST>(mut self, state: ST) -> RequireBuilder<B, ST, T> {
RequireBuilder{
predicate: None, // image recreation here
fallback: self.fallback,
state: Some(state),
redirect_field: self.redirect_field,
login_url: self.login_url
}
}
} 2. Have 2 variations of new, with state and without.I don't like this option at all |
I'm not able to look more closely at the moment, but is there any guidance via the way Axum handles this? |
I haven’t found a clear pattern how to handles this. While it’s an important quality of life feature, I think it can be done later. For now, I’ll start with implementing custom futures to avoid using Box, and I’ll focus on writing more comprehensive tests, as the current ones are almost nonexistent. Edit: I realised, I implemented the fallback handler in a wrong place, It was supposed to be fallback for permissions. This is a good opportunity to add simple parameters functionality like in your example. |
The current implementation is mostly functional, but several improvements are still needed:
|
change todos remove AuthzBackend where it is unnecessary
remove the redundant where clause from RequireBuilder
Even though this actually works, I'm totally doing it the wrong way. After digging through the tower-rs implementation, I realised I should've gone with a layer stacking/composition approach instead. And the way I'm accepting closures from users is pretty messy - doing it through traits would be way cleaner. 😞 |
I think there's some pretty good examples in tower and especially tower-http, which I seem to recall has a couple examples of middleware that take closures or callbacks. |
move Require layer to module file remove outdated documentation temporarily remove Debug temporarily add pin-project dependency
remove fallback params comment-out combinations test
reformat code remove own future todo remove unused imports fix DefaultFallback visibility
add additional bounds to fallback() for clearer errors
change tests accordingly
Summary
What do you think of the builder, specifically?Example without statelet require = RequireBuilder::<Backend>::new()
.fallback(|_| async { StatusCode::GONE.into_response() }) // New format with traits (Closure)
.fallback(RedirectFallback::new().login_url("/login")) // New format with traits (Builder)
.on_restrict(Rstr::from_closure(|_| async { // Old format with enums (shown on restrict fn)
StatusCode::UNAUTHORIZED.into_response()
}))
.build(); Example with statelet require_login = RequireBuilder::new_with_state(state.clone())
.fallback(RedirectFallback { // New format implemented with traits (Raw)
redirect_field: None,
login_url: Some("/login".to_string()),
})
.predicate(Predicate::from_closure(f)) // Old format with enums
.on_restrict(Rstr::from_closure(|_| async { // Old format with enums
StatusCode::UNAUTHORIZED.into_response()
}))
.build(); |
From my view this is much nicer way of doing things, especially if we intend to continue building support into this crate. |
- Move restrict handler to trait implementation - Rename AsyncFallback to AsyncFallbackHandler for clarity - Add builder factory access methods to Require - Clean up completed todos and add performance optimization notes
This is a quick fix
This solution does not fully support dependency injection config.
login_url
as a static string in thelogin_required!
macro.Example
Why?
In my app, the
login_url
comes from configuration injected via dependency injection:Currently this fix would allow this. It still requires creating a static reference 😢
While a
OnceLock
-based static config is technically an option, I don’t think libraries should force consumers to adopt that pattern:Notes
from_fn
tofrom_fn_with_state
, though I didn’t explore that path in this PR.require_permission!
macro.