130 lines
5.1 KiB
Rust
130 lines
5.1 KiB
Rust
//! Casbin-based authorization layer.
|
|
//!
|
|
//! Authentication stays with Loco's JWT cookie (see [`crate::shared::guard`]);
|
|
//! Casbin only answers "is this subject allowed on this path?". At request time
|
|
//! [`inject_subject`] resolves the caller's role from the JWT and stamps it onto
|
|
//! the request as `CasbinVals`, then [`CasbinAxumLayer`] enforces the policy in
|
|
//! `config/casbin/{model.conf,policy.csv}`.
|
|
//!
|
|
//! The model is deny-override: everything is allowed by default and the policy
|
|
//! only *denies* non-admins under `/admin/*`. The per-handler
|
|
//! [`guard::current_admin`] checks stay in place as defense in depth.
|
|
//!
|
|
//! DECISION — single hardcoded admin: there is intentionally no stored `role`.
|
|
//! The one admin is the user whose email matches `ADMIN_EMAIL` in `.env`
|
|
//! (granted in `admin_seeder`, detected by [`guard::is_admin`]); every other
|
|
//! authenticated user is `customer` and the rest are `anonymous`. This is a
|
|
//! deliberate fit for the current scale, not a constraint of the design.
|
|
//!
|
|
//! Upgrade path when more roles are needed (each step is localized): add a
|
|
//! `role` column to `users` (migration), set it on register/seed, read
|
|
//! `user.role` in [`inject_subject`] instead of the `is_admin` check below, and
|
|
//! add `p`/`g` lines to `config/casbin/policy.csv`. The enforcement layer,
|
|
//! `after_routes` wiring, and tests are unaffected by that change.
|
|
|
|
use axum::{
|
|
extract::{Request, State},
|
|
middleware::Next,
|
|
response::Response,
|
|
};
|
|
use axum_casbin::{
|
|
casbin::{DefaultModel, FileAdapter},
|
|
CasbinAxumLayer, CasbinVals,
|
|
};
|
|
use axum_extra::extract::cookie::CookieJar;
|
|
use loco_rs::prelude::*;
|
|
|
|
use crate::shared::guard;
|
|
|
|
const MODEL_PATH: &str = "config/casbin/model.conf";
|
|
const POLICY_PATH: &str = "config/casbin/policy.csv";
|
|
|
|
/// Build the Casbin enforcement layer from the on-disk model + policy.
|
|
pub async fn layer() -> Result<CasbinAxumLayer> {
|
|
let model = DefaultModel::from_file(MODEL_PATH)
|
|
.await
|
|
.map_err(|e| Error::Message(format!("casbin model load failed: {e}")))?;
|
|
let adapter = FileAdapter::new(POLICY_PATH);
|
|
CasbinAxumLayer::new(model, adapter)
|
|
.await
|
|
.map_err(|e| Error::Message(format!("casbin enforcer init failed: {e}")))
|
|
}
|
|
|
|
/// Resolve the caller's role from the Loco JWT cookie and attach it as the
|
|
/// Casbin subject. Always sets *some* subject (anonymous requests included) so
|
|
/// the enforcer never sees an empty value.
|
|
pub async fn inject_subject(
|
|
State(ctx): State<AppContext>,
|
|
jar: CookieJar,
|
|
mut req: Request,
|
|
next: Next,
|
|
) -> Response {
|
|
let subject = match guard::current_user(&ctx, &jar).await {
|
|
Some(user) if guard::is_admin(&ctx, &user) => "admin",
|
|
Some(_) => "customer",
|
|
None => "anonymous",
|
|
};
|
|
req.extensions_mut().insert(CasbinVals {
|
|
subject: subject.to_string(),
|
|
domain: None,
|
|
});
|
|
next.run(req).await
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use axum_casbin::casbin::{CoreApi, DefaultModel, Enforcer, FileAdapter};
|
|
|
|
async fn enforcer() -> Enforcer {
|
|
let model = DefaultModel::from_file(super::MODEL_PATH).await.unwrap();
|
|
let adapter = FileAdapter::new(super::POLICY_PATH);
|
|
Enforcer::new(model, adapter).await.unwrap()
|
|
}
|
|
|
|
async fn allowed(e: &Enforcer, sub: &str, obj: &str, act: &str) -> bool {
|
|
e.enforce((sub, obj, act)).unwrap()
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn admin_subtree_is_admin_only() {
|
|
let e = enforcer().await;
|
|
|
|
// Admins reach the protected subtree (any method, nested paths).
|
|
assert!(allowed(&e, "admin", "/admin/dashboard", "GET").await);
|
|
assert!(allowed(&e, "admin", "/admin/orders/5", "POST").await);
|
|
|
|
// Customers and anonymous are denied there.
|
|
assert!(!allowed(&e, "customer", "/admin/dashboard", "GET").await);
|
|
assert!(!allowed(&e, "customer", "/admin/orders/5", "POST").await);
|
|
assert!(!allowed(&e, "anonymous", "/admin/products", "GET").await);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn admin_only_endpoints_outside_admin_subtree() {
|
|
let e = enforcer().await;
|
|
|
|
// Admin JSON API and image upload are admin-only.
|
|
assert!(allowed(&e, "admin", "/api/admin/dashboard", "GET").await);
|
|
assert!(allowed(&e, "admin", "/images/upload", "POST").await);
|
|
assert!(!allowed(&e, "customer", "/api/admin/dashboard", "GET").await);
|
|
assert!(!allowed(&e, "anonymous", "/images/upload", "POST").await);
|
|
|
|
// Public image serving stays open for everyone.
|
|
assert!(allowed(&e, "anonymous", "/images/logo.png", "GET").await);
|
|
assert!(allowed(&e, "customer", "/images/logo.png", "GET").await);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn storefront_is_open_to_everyone() {
|
|
let e = enforcer().await;
|
|
|
|
for sub in ["admin", "customer", "anonymous"] {
|
|
assert!(allowed(&e, sub, "/", "GET").await);
|
|
assert!(allowed(&e, sub, "/shop", "GET").await);
|
|
assert!(allowed(&e, sub, "/login", "POST").await);
|
|
// The bare /admin entry stays open so it can redirect to /login.
|
|
assert!(allowed(&e, sub, "/admin", "GET").await);
|
|
}
|
|
}
|
|
}
|