RBAC via casbin
Some checks failed
CI / Check Style (push) Has been cancelled
CI / Run Clippy (push) Has been cancelled
CI / Run Tests (push) Has been cancelled

This commit is contained in:
Priec
2026-06-18 17:51:29 +02:00
parent ed607e3d27
commit 7da4109584
7 changed files with 542 additions and 8 deletions

356
Cargo.lock generated
View File

@@ -26,6 +26,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75"
dependencies = [
"cfg-if",
"const-random",
"getrandom 0.3.4",
"once_cell",
"version_check",
@@ -268,6 +269,18 @@ dependencies = [
"tracing",
]
[[package]]
name = "axum-casbin"
version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "632c40b424a3ee65c18058b23ad1a7c361bf8989fee9d7dbc8347b301b52459e"
dependencies = [
"axum",
"casbin",
"tokio",
"tower 0.5.3",
]
[[package]]
name = "axum-core"
version = "0.5.6"
@@ -541,6 +554,12 @@ dependencies = [
"syn 1.0.109",
]
[[package]]
name = "bytecount"
version = "0.6.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "175812e0be2bccb6abe50bb8d566126198344f707e304f45c648fd8f2cc0365e"
[[package]]
name = "byteorder"
version = "1.5.0"
@@ -559,6 +578,66 @@ version = "2.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6bd91ee7b2422bcb158d90ef4d14f75ef67f340943fc4149891dcce8f8b972a3"
[[package]]
name = "camino"
version = "1.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e629a66d692cb9ff1a1c664e41771b3dcaf961985a9774c0eb0bd1b51cf60a48"
dependencies = [
"serde_core",
]
[[package]]
name = "cargo-platform"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e35af189006b9c0f00a064685c727031e3ed2d8020f7ba284d78cc2671bd36ea"
dependencies = [
"serde",
]
[[package]]
name = "cargo_metadata"
version = "0.14.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4acbb09d9ee8e23699b9634375c72795d095bf268439da88562cf9b501f181fa"
dependencies = [
"camino",
"cargo-platform",
"semver",
"serde",
"serde_json",
]
[[package]]
name = "casbin"
version = "2.20.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c53f7476c2d0d9cd7ccc88c16ffc5c7889a0497b3462b10b12b5329adde69665"
dependencies = [
"async-trait",
"fixedbitset",
"getrandom 0.3.4",
"hashlink 0.9.1",
"mini-moka",
"once_cell",
"parking_lot",
"petgraph",
"regex",
"rhai",
"serde",
"serde_json",
"thiserror 1.0.69",
"tokio",
"wasm-bindgen-test",
]
[[package]]
name = "cast"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5"
[[package]]
name = "cc"
version = "1.2.62"
@@ -755,6 +834,26 @@ version = "0.9.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
[[package]]
name = "const-random"
version = "0.1.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "87e00182fe74b066627d63b85fd550ac2998d4b0bd86bfed477a0ae4c7c71359"
dependencies = [
"const-random-macro",
]
[[package]]
name = "const-random-macro"
version = "0.1.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e"
dependencies = [
"getrandom 0.2.17",
"once_cell",
"tiny-keccak",
]
[[package]]
name = "cookie"
version = "0.18.1"
@@ -888,6 +987,12 @@ dependencies = [
"regex",
]
[[package]]
name = "crunchy"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
[[package]]
name = "crypto-common"
version = "0.1.7"
@@ -956,6 +1061,19 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "dashmap"
version = "5.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "978747c1d849a7d2ee5e8adc0159961c48fb7e5db2f06af6723b80123bb53856"
dependencies = [
"cfg-if",
"hashbrown 0.14.5",
"lock_api",
"once_cell",
"parking_lot_core",
]
[[package]]
name = "dashmap"
version = "6.1.0"
@@ -1172,6 +1290,15 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "error-chain"
version = "0.12.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2d2f06b9cac1506ece98fe3231e3cc9c4410ec3d5b1f24ae1c8946f0742cdefc"
dependencies = [
"version_check",
]
[[package]]
name = "etcetera"
version = "0.8.0"
@@ -1206,6 +1333,12 @@ version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
[[package]]
name = "fixedbitset"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80"
[[package]]
name = "flate2"
version = "1.1.9"
@@ -1294,7 +1427,7 @@ checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095"
dependencies = [
"futures-core",
"futures-sink",
"spin",
"spin 0.9.8",
]
[[package]]
@@ -1581,6 +1714,9 @@ name = "hashbrown"
version = "0.14.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
dependencies = [
"ahash 0.8.12",
]
[[package]]
name = "hashbrown"
@@ -1599,6 +1735,15 @@ version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
[[package]]
name = "hashlink"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af"
dependencies = [
"hashbrown 0.14.5",
]
[[package]]
name = "hashlink"
version = "0.10.0"
@@ -2118,6 +2263,7 @@ version = "0.1.0"
dependencies = [
"async-trait",
"axum",
"axum-casbin",
"axum-extra",
"bytes",
"chrono",
@@ -2169,7 +2315,7 @@ version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
dependencies = [
"spin",
"spin 0.9.8",
]
[[package]]
@@ -2302,7 +2448,7 @@ dependencies = [
"clap",
"colored 3.1.1",
"cruet 0.13.3",
"dashmap",
"dashmap 6.1.0",
"duct",
"duct_sh",
"english-to-cron",
@@ -2463,6 +2609,31 @@ dependencies = [
"unicase",
]
[[package]]
name = "mini-moka"
version = "0.10.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c325dfab65f261f386debee8b0969da215b3fa0037e74c8a1234db7ba986d803"
dependencies = [
"crossbeam-channel",
"crossbeam-utils",
"dashmap 5.5.3",
"skeptic",
"smallvec",
"tagptr",
"triomphe",
]
[[package]]
name = "minicov"
version = "0.3.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4869b6a491569605d66d3952bcdf03df789e5b536e5f0cf7758a7f08a55ae24d"
dependencies = [
"cc",
"walkdir",
]
[[package]]
name = "minimal-lexical"
version = "0.2.1"
@@ -2521,7 +2692,7 @@ dependencies = [
"httparse",
"memchr",
"mime",
"spin",
"spin 0.9.8",
"version_check",
]
@@ -2544,6 +2715,15 @@ dependencies = [
"memoffset",
]
[[package]]
name = "no-std-compat"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b93853da6d84c2e3c7d730d6473e8817692dd89be387eb01b94d7f108ecb5b8c"
dependencies = [
"spin 0.5.2",
]
[[package]]
name = "nom"
version = "7.1.3"
@@ -2677,6 +2857,9 @@ name = "once_cell"
version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
dependencies = [
"portable-atomic",
]
[[package]]
name = "once_cell_polyfill"
@@ -2684,6 +2867,12 @@ version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
[[package]]
name = "oorandom"
version = "11.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e"
[[package]]
name = "opendal"
version = "0.54.1"
@@ -2870,6 +3059,16 @@ dependencies = [
"sha2",
]
[[package]]
name = "petgraph"
version = "0.6.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4c5cc86750666a3ed20bdaf5ca2a0344f9c67674cae0515bec2da16fbaa47db"
dependencies = [
"fixedbitset",
"indexmap",
]
[[package]]
name = "pgvector"
version = "0.4.1"
@@ -3105,6 +3304,17 @@ dependencies = [
"syn 1.0.109",
]
[[package]]
name = "pulldown-cmark"
version = "0.9.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57206b407293d2bcd3af849ce869d52068623f19e1b5ff8e8778e3309439682b"
dependencies = [
"bitflags",
"memchr",
"unicase",
]
[[package]]
name = "quick-xml"
version = "0.38.4"
@@ -3128,7 +3338,7 @@ dependencies = [
"quinn-udp",
"rustc-hash",
"rustls",
"socket2 0.5.10",
"socket2 0.6.3",
"thiserror 2.0.18",
"tokio",
"tracing",
@@ -3165,9 +3375,9 @@ dependencies = [
"cfg_aliases",
"libc",
"once_cell",
"socket2 0.5.10",
"socket2 0.6.3",
"tracing",
"windows-sys 0.52.0",
"windows-sys 0.60.2",
]
[[package]]
@@ -3413,6 +3623,36 @@ dependencies = [
"thiserror 2.0.18",
]
[[package]]
name = "rhai"
version = "1.25.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dd4dd0f8c36625202a4ba553c416c19b719947cd2a31d1bda06126e4a5727daf"
dependencies = [
"ahash 0.8.12",
"bitflags",
"no-std-compat",
"num-traits",
"once_cell",
"rhai_codegen",
"serde",
"smallvec",
"smartstring",
"thin-vec",
"web-time",
]
[[package]]
name = "rhai_codegen"
version = "3.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3cd3a7535e50bf36857e7be7bec276d334e8c2dfa469c2201226fd01638ea5ca"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "ring"
version = "0.17.14"
@@ -3881,6 +4121,10 @@ name = "semver"
version = "1.0.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
dependencies = [
"serde",
"serde_core",
]
[[package]]
name = "serde"
@@ -4174,6 +4418,21 @@ version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649"
[[package]]
name = "skeptic"
version = "0.13.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "16d23b015676c90a0f01c197bfdc786c20342c73a0afdda9025adb0bc42940a8"
dependencies = [
"bytecount",
"cargo_metadata",
"error-chain",
"glob",
"pulldown-cmark",
"tempfile",
"walkdir",
]
[[package]]
name = "slab"
version = "0.4.12"
@@ -4199,6 +4458,18 @@ dependencies = [
"serde",
]
[[package]]
name = "smartstring"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3fb72c633efbaa2dd666986505016c32c3044395ceaf881518399d2f4127ee29"
dependencies = [
"autocfg",
"serde",
"static_assertions",
"version_check",
]
[[package]]
name = "socket2"
version = "0.5.10"
@@ -4219,6 +4490,12 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "spin"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e63cff320ae2c57904679ba7cb63280a3dc4613885beafb148ee7bf9aa9042d"
[[package]]
name = "spin"
version = "0.9.8"
@@ -4270,7 +4547,7 @@ dependencies = [
"futures-io",
"futures-util",
"hashbrown 0.15.5",
"hashlink",
"hashlink 0.10.0",
"indexmap",
"log",
"memchr",
@@ -4619,6 +4896,15 @@ dependencies = [
"unicode-segmentation",
]
[[package]]
name = "thin-vec"
version = "0.2.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b0f7e269b48f0a7dd0146680fa24b50cc67fc0373f086a5b2f99bd084639b482"
dependencies = [
"serde",
]
[[package]]
name = "thiserror"
version = "1.0.69"
@@ -4699,6 +4985,15 @@ dependencies = [
"time-core",
]
[[package]]
name = "tiny-keccak"
version = "2.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237"
dependencies = [
"crunchy",
]
[[package]]
name = "tinystr"
version = "0.8.3"
@@ -5038,6 +5333,12 @@ dependencies = [
"serde",
]
[[package]]
name = "triomphe"
version = "0.1.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dd69c5aa8f924c7519d6372789a74eac5b94fb0f8fcf0d4a97eb0bfc3e785f39"
[[package]]
name = "try-lock"
version = "0.2.5"
@@ -5383,6 +5684,45 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "wasm-bindgen-test"
version = "0.3.71"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "af5ec93229ad9ccd0a545a516dec76dc276613f278f6a91aa6b463d5b33d42d0"
dependencies = [
"async-trait",
"cast",
"js-sys",
"libm",
"minicov",
"nu-ansi-term",
"num-traits",
"oorandom",
"serde",
"serde_json",
"wasm-bindgen",
"wasm-bindgen-futures",
"wasm-bindgen-test-macro",
"wasm-bindgen-test-shared",
]
[[package]]
name = "wasm-bindgen-test-macro"
version = "0.3.71"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c81b9fef827e575e0e54431736d1baa0d700315d8c62cfef1f61fa3aad0cbeb"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "wasm-bindgen-test-shared"
version = "0.2.121"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4f4d8ae7ad5440360e9799dfd42857d126454a88441ddf72d288ef83fa47f527"
[[package]]
name = "wasm-encoder"
version = "0.244.0"

View File

@@ -45,6 +45,7 @@ unic-langid = { version = "0.9" }
# /view engine
axum-extra = { version = "0.10", features = ["form"] }
bytes = { version = "1" }
axum-casbin = "1.3.0"
[[bin]]
name = "kompress-eshop-cli"

24
config/casbin/model.conf Normal file
View File

@@ -0,0 +1,24 @@
# Casbin access model for the storefront.
#
# Request is (subject, object, action) = (role, request-path, HTTP-method);
# axum-casbin supplies path + method automatically and the subject comes from
# our JWT-derived CasbinVals (see src/shared/rbac.rs).
#
# Deny-override: every request is allowed unless a matching policy line marks it
# `deny`. That keeps the public storefront fully open and lets the policy file
# carve out the protected `/admin/*` subtree for non-admins only.
[request_definition]
r = sub, obj, act
[policy_definition]
p = sub, obj, act, eft
[role_definition]
g = _, _
[policy_effect]
e = !some(where (p.eft == deny))
[matchers]
m = (r.sub == p.sub || g(r.sub, p.sub)) && keyMatch(r.obj, p.obj) && regexMatch(r.act, p.act)

25
config/casbin/policy.csv Normal file
View File

@@ -0,0 +1,25 @@
# Authorization policy. Format: p, subject(role), object(path-pattern), action(method-regex), effect
#
# DECISION: this app intentionally runs with a SINGLE hardcoded admin (the user
# whose email matches ADMIN_EMAIL in .env, via guard::is_admin / admin_seeder).
# Everyone else is `customer` (logged in) or `anonymous` (not). There is no
# stored `role` column yet. This is a deliberate choice for current scale, not a
# limitation of the wiring — see src/shared/rbac.rs for the upgrade path.
#
# Deny everyone except admins under the admin subtree. `keyMatch` treats the
# trailing `*` as "anything after /admin/", so /admin/dashboard, /admin/orders/5
# etc. are all covered; the bare /admin entry point stays open so it can redirect
# to /login. Admins match no deny rule and so are allowed through.
#
# To grow this: give users a real role, emit it as the subject in
# src/shared/rbac.rs, then add `p` lines (allow/deny) and `g, <user>, <role>`
# mappings here.
p, customer, /admin/*, .*, deny
p, anonymous, /admin/*, .*, deny
# Admin-only endpoints that live outside the /admin/* subtree: the admin JSON
# API and the image upload. Public image serving (/images/{filename}) is GET and
# not matched here, so it stays open.
p, customer, /api/admin/*, .*, deny
p, anonymous, /api/admin/*, .*, deny
p, customer, /images/upload, .*, deny
p, anonymous, /images/upload, .*, deny
Can't render this file because it has a wrong number of fields in line 2.

View File

@@ -56,6 +56,20 @@ impl Hooks for App {
environment.load()
}
/// Attach the Casbin authorization layer on top of all routes. Order
/// matters: `inject_subject` is the outermost layer so it runs first and
/// stamps the JWT-derived role onto the request before the inner
/// `CasbinAxumLayer` enforces the policy. See `shared::rbac`.
async fn after_routes(router: axum::Router, ctx: &AppContext) -> Result<axum::Router> {
let casbin = crate::shared::rbac::layer().await?;
Ok(router
.layer(casbin)
.layer(axum::middleware::from_fn_with_state(
ctx.clone(),
crate::shared::rbac::inject_subject,
)))
}
async fn initializers(_ctx: &AppContext) -> Result<Vec<Box<dyn Initializer>>> {
Ok(vec![
Box::new(initializers::view_engine::ViewEngineInitializer),

View File

@@ -2,5 +2,6 @@
pub mod guard;
pub mod money;
pub mod rbac;
pub mod settings;
pub mod slug;

129
src/shared/rbac.rs Normal file
View File

@@ -0,0 +1,129 @@
//! 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);
}
}
}