Files
kompress_eshop/src/shared/rbac.rs
Priec 7da4109584
Some checks failed
CI / Check Style (push) Has been cancelled
CI / Run Clippy (push) Has been cancelled
CI / Run Tests (push) Has been cancelled
RBAC via casbin
2026-06-18 18:02:44 +02:00

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);
}
}
}