use crate::store::Permissions;
use rustlavel_auth::guard::{AuthExt, Identity, DEFAULT_LOGIN_PATH};
use rustlavel_core::{Config, Error, Json, Result};
use rustlavel_http::handler::BoxFuture;
use rustlavel_http::middleware::{Middleware, Next};
use rustlavel_http::response::IntoResponse;
use rustlavel_http::{Request, Response, Status};
#[derive(Debug, Clone, PartialEq, Eq)]
enum Requirement {
Permission(String),
Role(String),
}
impl Requirement {
fn noun(&self) -> &'static str {
match self {
Requirement::Permission(_) => "permission",
Requirement::Role(_) => "role",
}
}
fn name(&self) -> &str {
match self {
Requirement::Permission(name) | Requirement::Role(name) => name,
}
}
}
#[derive(Debug, Clone)]
pub struct Can {
requirement: Requirement,
login_path: String,
}
impl Can {
pub fn permission(name: impl Into<String>) -> Self {
Can::with(Requirement::Permission(name.into()))
}
pub fn role(name: impl Into<String>) -> Self {
Can::with(Requirement::Role(name.into()))
}
fn with(requirement: Requirement) -> Self {
Can { requirement, login_path: DEFAULT_LOGIN_PATH.to_string() }
}
pub fn from_config(mut self, config: &Config) -> Self {
self.login_path = config.string("auth.login_path", DEFAULT_LOGIN_PATH);
self
}
pub fn login_path(mut self, path: impl Into<String>) -> Self {
self.login_path = path.into();
self
}
}
impl Middleware for Can {
fn handle(&self, request: Request, next: Next) -> BoxFuture<Response> {
let requirement = self.requirement.clone();
let login_path = self.login_path.clone();
Box::pin(async move {
let Some(identity) = current_identity(&request) else {
if request.wants_json() {
return Response::new(Status::UNAUTHORIZED)
.with_json(Json::object([("message", Json::from("Unauthenticated."))]));
}
if let Some(guard) = request.try_auth() {
guard.remember_intended(request.target());
}
return Response::see_other(login_path);
};
let store = match resolve(&request) {
Ok(store) => store,
Err(error) => return error.into_response(),
};
let user_id = match user_id(&identity) {
Ok(id) => id,
Err(error) => return error.into_response(),
};
let allowed = match &requirement {
Requirement::Permission(name) => store.has_permission(user_id, name).await,
Requirement::Role(name) => store.has_role(user_id, name).await,
};
match allowed {
Ok(true) => next.run(request).await,
Ok(false) => forbidden(&requirement, request.wants_json()),
Err(error) => Error::msg(format!(
"checking the `{}` {} failed: {error}",
requirement.name(),
requirement.noun()
))
.into_response(),
}
})
}
}
fn current_identity(request: &Request) -> Option<Identity> {
request
.identity()
.cloned()
.or_else(|| request.try_auth().and_then(|guard| guard.user_id()).map(Identity::new))
}
fn resolve(request: &Request) -> Result<Permissions> {
request.state::<Permissions>().cloned().ok_or_else(|| {
Error::msg(
"the `can` middleware needs a `Permissions` store in application state, and there is \
none. Register it with `App::new().plugin(Rbac::new(db))`, or put it there yourself \
with `Context::builder().state(Permissions::new(db))`. Refusing the request rather \
than allowing it.",
)
})
}
fn user_id(identity: &Identity) -> Result<i64> {
identity.id_as::<i64>().ok_or_else(|| {
Error::msg(format!(
"the authenticated identity `{identity}` is not a number, and the RBAC tables key \
users by a 64-bit integer id. Either log users in by their numeric primary key, or \
use a different authorization layer for this application."
))
})
}
fn forbidden(requirement: &Requirement, wants_json: bool) -> Response {
let response = Response::new(Status::FORBIDDEN);
if wants_json {
response.with_json(Json::object([
("message", Json::from("This action is unauthorized.")),
(requirement.noun(), Json::from(requirement.name())),
]))
} else {
response.with_text(format!(
"This action requires the `{}` {}.",
requirement.name(),
requirement.noun()
))
}
}
pub trait RbacExt {
fn permissions(&self) -> Result<Permissions>;
fn rbac_user_id(&self) -> Option<i64>;
fn can(&self, permission: &str) -> impl Future<Output = Result<bool>> + Send;
fn has_role(&self, role: &str) -> impl Future<Output = Result<bool>> + Send;
fn permission_list(&self) -> impl Future<Output = Result<Vec<String>>> + Send;
}
impl RbacExt for Request {
fn permissions(&self) -> Result<Permissions> {
resolve(self)
}
fn rbac_user_id(&self) -> Option<i64> {
current_identity(self).and_then(|identity| identity.id_as::<i64>())
}
async fn can(&self, permission: &str) -> Result<bool> {
match self.rbac_user_id() {
Some(id) => self.permissions()?.has_permission(id, permission).await,
None => Ok(false),
}
}
async fn has_role(&self, role: &str) -> Result<bool> {
match self.rbac_user_id() {
Some(id) => self.permissions()?.has_role(id, role).await,
None => Ok(false),
}
}
async fn permission_list(&self) -> Result<Vec<String>> {
match self.rbac_user_id() {
Some(id) => self.permissions()?.permissions_for(id).await,
None => Ok(Vec::new()),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::grants::Grants;
use rustlavel_auth::key::AppKey;
use rustlavel_auth::store::MemoryStore;
use rustlavel_auth::{Authenticate, SessionManager};
use rustlavel_core::Context;
use rustlavel_db::{Database, DatabaseConfig};
use rustlavel_http::{Method, Router, TestClient, TestResponse};
use std::collections::BTreeSet;
fn offline() -> Database {
Database::lazy(
DatabaseConfig::from_url("postgres://nobody:nothing@127.0.0.1:1/none").unwrap(),
)
.unwrap()
}
fn set<const N: usize>(names: [&str; N]) -> BTreeSet<String> {
names.iter().map(|n| (*n).to_string()).collect()
}
fn store() -> Permissions {
let store = Permissions::new(offline());
store.prime(
41,
Grants {
roles: set(["editor"]),
granted: set(["posts.*"]),
denied: set(["posts.delete"]),
is_super: false,
},
);
store.prime(7, Grants::default());
store
}
fn client(with_store: bool) -> TestClient {
let mut router = Router::new();
router.middleware(SessionManager::new(&AppKey::from_bytes([9u8; 32]), MemoryStore::new()));
router.get("/login-as/{id}", |request: Request| async move {
let id = request.param("id").unwrap_or_default().to_string();
request.auth().login_using_id(id);
"logged in"
});
router.group("/posts", |r| {
r.middleware(Authenticate::new().login_path("/login"));
r.group("/publish", |r| {
r.middleware(Can::permission("posts.publish"));
r.get("", |_request: Request| async { "published" });
});
r.group("/delete", |r| {
r.middleware(Can::permission("posts.delete"));
r.get("", |_request: Request| async { "deleted" });
});
});
router.group("/admin", |r| {
r.middleware(Can::role("admin"));
r.get("", |_request: Request| async { "the admin area" });
});
router.group("/editor", |r| {
r.middleware(Can::role("editor"));
r.get("", |_request: Request| async { "the editor area" });
});
router.group("/api", |r| {
r.middleware(Can::permission("posts.publish"));
r.get("/publish", |_request: Request| async { "published" });
});
router.get("/what-can-i-do", |request: Request| async move {
let list = request.permission_list().await?;
let editor = request.has_role("editor").await?;
Ok::<_, Error>(format!("{}|editor={editor}", list.join(",")))
});
let mut builder = Context::builder();
if with_store {
builder = builder.state(store());
}
TestClient::new(router).with_context(builder.build())
}
fn cookie_of(response: &TestResponse) -> String {
response
.header("set-cookie")
.expect("the session middleware sets a cookie")
.split(';')
.next()
.unwrap()
.to_string()
}
async fn logged_in(client: &TestClient, id: i64) -> String {
cookie_of(&client.get(&format!("/login-as/{id}")).await.assert_ok())
}
async fn get_as(client: &TestClient, cookie: &str, path: &str) -> TestResponse {
client.send(Request::new(Method::Get, path).with_header("cookie", cookie)).await
}
#[tokio::test]
async fn a_guest_is_sent_to_the_login_page() {
client(true).get("/posts/publish").await.assert_redirect("/login");
client(true).get("/editor").await.assert_redirect("/login");
}
#[tokio::test]
async fn a_guest_asking_for_json_gets_401_rather_than_a_redirect() {
let response = client(true)
.send(
Request::new(Method::Get, "/api/publish")
.with_header("accept", "application/json"),
)
.await;
response.assert_status(401).assert_json("message", "Unauthenticated.");
}
#[tokio::test]
async fn a_user_with_the_permission_gets_through() {
let client = client(true);
let cookie = logged_in(&client, 41).await;
get_as(&client, &cookie, "/posts/publish").await.assert_ok().assert_see("published");
}
#[tokio::test]
async fn a_user_without_the_permission_gets_403() {
let client = client(true);
let cookie = logged_in(&client, 7).await;
get_as(&client, &cookie, "/posts/publish")
.await
.assert_status(403)
.assert_see("posts.publish");
}
#[tokio::test]
async fn a_direct_deny_closes_a_route_the_wildcard_would_have_opened() {
let client = client(true);
let cookie = logged_in(&client, 41).await;
get_as(&client, &cookie, "/posts/delete").await.assert_status(403);
}
#[tokio::test]
async fn a_logged_in_client_asking_for_json_gets_403_with_the_missing_permission() {
let client = client(true);
let cookie = logged_in(&client, 7).await;
client
.send(
Request::new(Method::Get, "/api/publish")
.with_header("cookie", cookie)
.with_header("accept", "application/json"),
)
.await
.assert_status(403)
.assert_json("message", "This action is unauthorized.")
.assert_json("permission", "posts.publish");
}
#[tokio::test]
async fn a_role_guard_admits_the_role_and_refuses_the_rest() {
let client = client(true);
let editor = logged_in(&client, 41).await;
get_as(&client, &editor, "/editor").await.assert_ok().assert_see("the editor area");
get_as(&client, &editor, "/admin").await.assert_status(403).assert_see("admin");
let nobody = logged_in(&client, 7).await;
get_as(&client, &nobody, "/editor").await.assert_status(403);
}
#[tokio::test]
async fn a_missing_store_fails_closed() {
let client = client(false);
let cookie = logged_in(&client, 41).await;
get_as(&client, &cookie, "/posts/publish").await.assert_status(500);
}
#[tokio::test]
async fn an_identity_that_is_not_a_number_is_a_configuration_error() {
let client = client(true);
let cookie = cookie_of(&client.get("/login-as/ada").await.assert_ok());
get_as(&client, &cookie, "/posts/publish").await.assert_status(500);
}
#[tokio::test]
async fn a_handler_can_ask_for_itself() {
let client = client(true);
let cookie = logged_in(&client, 41).await;
get_as(&client, &cookie, "/what-can-i-do")
.await
.assert_ok()
.assert_see("posts.*|editor=true");
}
#[tokio::test]
async fn a_guest_asking_from_a_handler_is_told_no_rather_than_asked_to_log_in() {
client(true).get("/what-can-i-do").await.assert_ok().assert_see("|editor=false");
}
#[test]
fn the_missing_store_error_says_how_to_fix_it() {
let message = resolve(&Request::new(Method::Get, "/")).unwrap_err().to_string();
assert!(message.contains("`Permissions` store"), "{message}");
assert!(message.contains("Rbac::new(db)"), "{message}");
assert!(message.contains("Refusing the request"), "{message}");
}
#[test]
fn a_non_numeric_identity_is_explained() {
let message = user_id(&Identity::new("ada")).unwrap_err().to_string();
assert!(message.contains("is not a number"), "{message}");
assert_eq!(user_id(&Identity::new("41")).unwrap(), 41);
}
}