//! 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 { 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, 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); } } }