diff --git a/Cargo.lock b/Cargo.lock index da137b7..226969f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/Cargo.toml b/Cargo.toml index 14eb5df..48f1d6b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/config/casbin/model.conf b/config/casbin/model.conf new file mode 100644 index 0000000..4688f80 --- /dev/null +++ b/config/casbin/model.conf @@ -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) diff --git a/config/casbin/policy.csv b/config/casbin/policy.csv new file mode 100644 index 0000000..1e7b748 --- /dev/null +++ b/config/casbin/policy.csv @@ -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, , ` +# 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 diff --git a/src/app.rs b/src/app.rs index 768b359..6410387 100644 --- a/src/app.rs +++ b/src/app.rs @@ -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 { + 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>> { Ok(vec![ Box::new(initializers::view_engine::ViewEngineInitializer), diff --git a/src/shared/mod.rs b/src/shared/mod.rs index a687049..33b70aa 100644 --- a/src/shared/mod.rs +++ b/src/shared/mod.rs @@ -2,5 +2,6 @@ pub mod guard; pub mod money; +pub mod rbac; pub mod settings; pub mod slug; diff --git a/src/shared/rbac.rs b/src/shared/rbac.rs new file mode 100644 index 0000000..4071dd5 --- /dev/null +++ b/src/shared/rbac.rs @@ -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 { + 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); + } + } +}