This is an automated email from the ASF dual-hosted git repository.
hsluoyz pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/casbin-rs.git
The following commit(s) were added to refs/heads/master by this push:
new 12bf6a7 fix: support subjectPriority, policy priority sorting and in
operator like Go
12bf6a7 is described below
commit 12bf6a709d7d2c086b8698795441ea17ec8ae0b8
Author: Yang Luo <[email protected]>
AuthorDate: Wed Oct 21 01:49:32 2026 +0800
fix: support subjectPriority, policy priority sorting and in operator like
Go
---
.github/workflows/ci.yml | 8 +++
src/config.rs | 24 ++++++++-
src/effector.rs | 8 ++-
src/enforcer.rs | 121 ++++++++++++++++++++++++++++++++++++++++++---
src/macros.rs | 2 +-
src/model/assertion.rs | 28 ++++++++---
src/model/default_model.rs | 3 ++
src/util.rs | 22 ++++++++-
tests/conformance.rs | 101 +++++++++++++++++++++++++++++++++++++
9 files changed, 296 insertions(+), 21 deletions(-)
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 7d52b0d..19c4188 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -18,6 +18,12 @@ jobs:
steps:
- name: Checkout Repository
uses: actions/checkout@v4
+ - name: Checkout Conformance Cases
+ uses: actions/checkout@v4
+ with:
+ repository: apache/casbin
+ path: casbin-conformance
+ sparse-checkout: conformance
- name: Install Rust toolchain ${{ matrix.rust }}
run: |
rustup toolchain install ${{ matrix.rust }} --profile minimal
--component rustfmt,clippy --target wasm32-unknown-unknown
@@ -62,6 +68,8 @@ jobs:
run: cargo test --no-default-features --features
runtime-async-std,cached,glob,ip,watcher,logging,incremental,explain
- name: Cargo Test For All Features Using tokio
run: cargo test --no-default-features --features
runtime-tokio,cached,glob,ip,watcher,logging,incremental,explain
+ env:
+ CASBIN_CONFORMANCE_DIR: ${{ github.workspace
}}/casbin-conformance/conformance
- name: Cargo Check Wasm
env:
RUSTFLAGS: '--cfg getrandom_backend="wasm_js"'
diff --git a/src/config.rs b/src/config.rs
index 99bf191..5522381 100644
--- a/src/config.rs
+++ b/src/config.rs
@@ -26,6 +26,27 @@ const DEFAULT_COMMENT: &str = "#";
const DEFAULT_COMMENT_SEM: &str = ";";
const DEFAULT_MULTI_LINE_SEPARATOR: &str = "\\";
+fn remove_inline_comment(line: &str) -> String {
+ let (body, multi_line) =
+ match line.strip_suffix(DEFAULT_MULTI_LINE_SEPARATOR) {
+ Some(body) => (body, true),
+ None => (line, false),
+ };
+ let body = match body.find(|c| {
+ DEFAULT_COMMENT.contains(c) || DEFAULT_COMMENT_SEM.contains(c)
+ }) {
+ Some(idx) => &body[..idx],
+ None => body,
+ }
+ .trim_end();
+
+ if multi_line {
+ format!("{}{}", body, DEFAULT_MULTI_LINE_SEPARATOR)
+ } else {
+ body.to_owned()
+ }
+}
+
pub(crate) struct Config {
data: HashMap<String, HashMap<String, String>>,
}
@@ -87,6 +108,7 @@ impl Config {
section = line[1..line.len() - 1].to_string();
} else {
let mut next_section = String::new();
+ line = remove_inline_comment(&line);
while line.ends_with(DEFAULT_MULTI_LINE_SEPARATOR) {
line = line[..line.len() - 1].trim_end().to_string();
@@ -109,7 +131,7 @@ impl Config {
next_section =
inner_line[1..inner_line.len() - 1].to_string();
} else {
- line.push_str(&inner_line);
+ line.push_str(&remove_inline_comment(&inner_line));
}
}
diff --git a/src/effector.rs b/src/effector.rs
index 1c0082a..43e286e 100644
--- a/src/effector.rs
+++ b/src/effector.rs
@@ -39,7 +39,8 @@ impl Effector for DefaultEffector {
let res = match expr {
"some(where (p_eft == allow))"
| "some(where (p_eft == allow)) && !some(where (p_eft == deny))"
- | "priority(p_eft) || deny" => false,
+ | "priority(p_eft) || deny"
+ | "subjectPriority(p_eft) || deny" => false,
"!some(where (p_eft == deny))" => true,
_ => panic!("unsupported effect: `{}`", expr),
};
@@ -93,6 +94,8 @@ impl EffectorStream for DefaultEffectStream {
self.done = true;
self.res = false;
+ #[cfg(feature = "explain")]
+ self.expl.clear();
push_index_if_explain!(self)
}
} else if self.expr == "!some(where (p_eft == deny))" {
@@ -102,7 +105,8 @@ impl EffectorStream for DefaultEffectStream {
push_index_if_explain!(self)
}
- } else if self.expr == "priority(p_eft) || deny"
+ } else if (self.expr == "priority(p_eft) || deny"
+ || self.expr == "subjectPriority(p_eft) || deny")
&& eft != EffectKind::Indeterminate
{
self.res = eft == EffectKind::Allow;
diff --git a/src/enforcer.rs b/src/enforcer.rs
index b4f8afd..28ddcde 100644
--- a/src/enforcer.rs
+++ b/src/enforcer.rs
@@ -7,7 +7,7 @@ use crate::{
error::{ModelError, PolicyError, RequestError},
get_or_err, get_or_err_with_context,
management_api::MgmtApi,
- model::{FunctionMap, Model, OperatorFunction},
+ model::{key_match, FunctionMap, Model, OperatorFunction},
rbac::{DefaultRoleManager, RoleManager},
register_g_function,
util::{escape_assertion, escape_eval},
@@ -26,6 +26,7 @@ use crate::watcher::Watcher;
use crate::{DefaultLogger, Logger};
use async_trait::async_trait;
+use hashlink::LinkedHashSet;
use once_cell::sync::Lazy;
use parking_lot::RwLock;
use rhai::{
@@ -52,7 +53,41 @@ def_package! {
static CASBIN_PACKAGE: Lazy<CasbinPackage> = Lazy::new(CasbinPackage::new);
-use std::{cmp::max, collections::HashMap, sync::Arc};
+use std::{
+ cmp::{max, Reverse},
+ collections::{HashMap, HashSet, VecDeque},
+ sync::Arc,
+};
+
+fn subject_hierarchy(
+ policies: &LinkedHashSet<Vec<String>>,
+) -> HashMap<(String, String), usize> {
+ let mut children: HashMap<(String, String), Vec<(String, String)>> =
+ HashMap::new();
+ let mut parents = Vec::new();
+ for rule in policies.iter().filter(|rule| rule.len() >= 2) {
+ let domain = rule.get(2).cloned().unwrap_or_default();
+ let child = (domain.clone(), rule[0].clone());
+ let parent = (domain, rule[1].clone());
+ parents.push(parent.clone());
+ children.entry(parent).or_default().push(child);
+ }
+
+ let nested: HashSet<_> = children.values().flatten().collect();
+ let mut levels = HashMap::new();
+ for root in parents.iter().filter(|x| !nested.contains(x)) {
+ let mut queue = VecDeque::from([(root, 0)]);
+ while let Some((node, level)) = queue.pop_front() {
+ if let Some(nodes) =
+ children.get(node).filter(|_| level < policies.len())
+ {
+ queue.extend(nodes.iter().map(|x| (x, level + 1)));
+ }
+ levels.insert(node.clone(), level);
+ }
+ }
+ levels
+}
type EventCallback = fn(&mut Enforcer, EventData);
@@ -413,14 +448,84 @@ impl Enforcer {
}
fn init_rm_map(&mut self) {
+ let model = self.model.get_model();
+ let token = |sec: &str| {
+ model
+ .get(sec)
+ .and_then(|asts| asts.get(sec))
+ .and_then(|ast| ast.tokens.get(1))
+ };
+ let domain_key_match = match (token("r"), token("p")) {
+ (Some(r_dom), Some(p_dom)) => model
+ .get("m")
+ .and_then(|asts| asts.get("m"))
+ .is_some_and(|ast| {
+ ast.value
+ .contains(&format!("keyMatch({}, {})", r_dom, p_dom))
+ }),
+ _ => false,
+ };
+ if let Some(ast_map) = model.get("g") {
+ for (ptype, ast) in ast_map {
+ self.rm_map.entry(ptype.to_owned()).or_insert_with(|| {
+ let mut rm = DefaultRoleManager::new(10);
+ if domain_key_match && ast.role_token_count() > 2 {
+ rm.matching_fn(None, Some(key_match));
+ }
+ Arc::new(RwLock::new(rm))
+ });
+ }
+ }
self.rm_map.entry("g".to_owned()).or_insert_with(|| {
Arc::new(RwLock::new(DefaultRoleManager::new(10)))
});
- if let Some(ast_map) = self.model.get_model().get("g") {
- for ptype in ast_map.keys() {
- self.rm_map.entry(ptype.to_owned()).or_insert_with(|| {
- Arc::new(RwLock::new(DefaultRoleManager::new(10)))
- });
+ }
+
+ fn sort_policies(&mut self) {
+ let model = self.model.get_mut_model();
+ let hierarchy = model
+ .get("e")
+ .and_then(|asts| asts.get("e"))
+ .filter(|ast| ast.value == "subjectPriority(p_eft) || deny")
+ .and_then(|_| model.get("g").and_then(|asts| asts.get("g")))
+ .map(|ast| subject_hierarchy(&ast.policy));
+
+ if let Some(asts) = model.get_mut("p") {
+ for (ptype, ast) in asts.iter_mut() {
+ let dom_index = ast
+ .tokens
+ .iter()
+ .position(|t| *t == format!("{}_dom", ptype));
+ let priority_index = ast
+ .tokens
+ .iter()
+ .position(|t| *t == format!("{}_priority", ptype));
+ if hierarchy.is_none() && priority_index.is_none() {
+ continue;
+ }
+
+ let mut policies: Vec<Vec<String>> =
+ ast.policy.drain().collect();
+ if let Some(hierarchy) = &hierarchy {
+ policies.sort_by_key(|rule| {
+ let domain = dom_index
+ .and_then(|i| rule.get(i))
+ .map_or("", String::as_str);
+ let name = rule.first().map_or("", String::as_str);
+ Reverse(
+ hierarchy
+ .get(&(domain.to_owned(), name.to_owned()))
+ .copied()
+ .unwrap_or(0),
+ )
+ });
+ }
+ if let Some(i) = priority_index {
+ policies.sort_by_key(|rule| {
+ rule.get(i).and_then(|x| x.parse::<i64>().ok())
+ });
+ }
+ ast.policy = policies.into_iter().collect();
}
}
}
@@ -802,6 +907,7 @@ impl CoreApi for Enforcer {
async fn load_policy(&mut self) -> Result<()> {
self.model.clear_policy();
self.adapter.load_policy(&mut *self.model).await?;
+ self.sort_policies();
if self.auto_build_role_links {
self.build_role_links()?;
@@ -815,6 +921,7 @@ impl CoreApi for Enforcer {
self.adapter
.load_filtered_policy(&mut *self.model, f)
.await?;
+ self.sort_policies();
if self.auto_build_role_links {
self.build_role_links()?;
diff --git a/src/macros.rs b/src/macros.rs
index 8721655..358413f 100644
--- a/src/macros.rs
+++ b/src/macros.rs
@@ -48,7 +48,7 @@ macro_rules! get_or_err_with_context {
macro_rules! register_g_function {
($enforcer:ident, $fname:ident, $ast:ident) => {{
let rm = Arc::clone(&$enforcer.rm_map[$fname]);
- let count = $ast.value.matches('_').count();
+ let count = $ast.role_token_count();
if count == 2 {
$enforcer.engine.register_fn(
diff --git a/src/model/assertion.rs b/src/model/assertion.rs
index 1041630..9065246 100644
--- a/src/model/assertion.rs
+++ b/src/model/assertion.rs
@@ -36,6 +36,16 @@ impl Default for Assertion {
}
impl Assertion {
+ pub(crate) fn role_token_count(&self) -> usize {
+ let params = match (self.value.find('('), self.value.find(')')) {
+ (Some(start), Some(end)) if start < end => {
+ self.value[start..end].matches('_').count()
+ }
+ _ => 0,
+ };
+ self.value.matches('_').count() - params
+ }
+
#[inline]
pub fn get_policy(&self) -> &LinkedHashSet<Vec<String>> {
&self.policy
@@ -51,7 +61,8 @@ impl Assertion {
rm: Arc<RwLock<dyn RoleManager>>,
) -> Result<()> {
let count = self.value.matches('_').count();
- if count < 2 {
+ let tokens = self.role_token_count();
+ if tokens < 2 {
return Err(ModelError::P(
r#"the number of "_" in role definition should be at least 2"#
.to_owned(),
@@ -66,11 +77,11 @@ impl Assertion {
)
.into());
}
- if count == 2 {
+ if tokens == 2 {
rm.write().add_link(&rule[0], &rule[1], None);
- } else if count == 3 {
+ } else if tokens == 3 {
rm.write().add_link(&rule[0], &rule[1], Some(&rule[2]));
- } else if count >= 4 {
+ } else if tokens >= 4 {
return Err(ModelError::P(
"Multiple domains are not supported".to_owned(),
)
@@ -88,7 +99,8 @@ impl Assertion {
d: EventData,
) -> Result<()> {
let count = self.value.matches('_').count();
- if count < 2 {
+ let tokens = self.role_token_count();
+ if tokens < 2 {
return Err(ModelError::P(
r#"the number of "_" in role definition should be at least 2"#
.to_owned(),
@@ -114,13 +126,13 @@ impl Assertion {
)
.into());
}
- if count == 2 {
+ if tokens == 2 {
if insert {
rm.write().add_link(&rule[0], &rule[1], None);
} else {
rm.write().delete_link(&rule[0], &rule[1], None)?;
}
- } else if count == 3 {
+ } else if tokens == 3 {
if insert {
rm.write().add_link(&rule[0], &rule[1],
Some(&rule[2]));
} else {
@@ -130,7 +142,7 @@ impl Assertion {
Some(&rule[2]),
)?;
}
- } else if count >= 4 {
+ } else if tokens >= 4 {
return Err(ModelError::P(
"Multiple domains are not supported".to_owned(),
)
diff --git a/src/model/default_model.rs b/src/model/default_model.rs
index 166d526..2223738 100644
--- a/src/model/default_model.rs
+++ b/src/model/default_model.rs
@@ -169,6 +169,9 @@ impl Model for DefaultModel {
.collect();
} else {
ast.value = escape_assertion(&ast.value);
+ if sec == "m" {
+ ast.value = escape_in_operator(&ast.value).into_owned();
+ }
}
if let Some(new_model) = self.model.get_mut(sec) {
diff --git a/src/util.rs b/src/util.rs
index 24cab15..9826b81 100644
--- a/src/util.rs
+++ b/src/util.rs
@@ -15,6 +15,7 @@ static ESC_G: Lazy<Regex> = Lazy::new(|| {
regex!(r"\b(g\d*)\(((?:\s*[r|p]\d*\.\w+\s*,\s*){1,2}\s*[r|p]\d*\.\w+\s*)\)")
});
static ESC_C: Lazy<Regex> = Lazy::new(|| regex!(r#"(\s*"[^"]*"?|\s*[^,]*)"#));
+static ESC_IN: Lazy<Regex> = Lazy::new(|| regex!(r"\bin\s*\(([^()]*)\)"));
pub(crate) static ESC_E: Lazy<Regex> =
Lazy::new(|| regex!(r"\beval\(([^)]*)\)"));
@@ -38,7 +39,20 @@ pub fn escape_assertion(s: &str) -> String {
}
let end = (end + 1).min(bytes.len());
res.push_str(&ESC_A.replace_all(&s[start..i], "${1}_"));
- res.push_str(&s[i..end]);
+ if quote == b'\'' && end - i >= 2 && bytes[end - 1] == quote {
+ res.push('"');
+ let mut escaped = false;
+ for c in s[i + 1..end - 1].chars() {
+ if c == '"' && !escaped {
+ res.push('\\');
+ }
+ escaped = c == '\\' && !escaped;
+ res.push(c);
+ }
+ res.push('"');
+ } else {
+ res.push_str(&s[i..end]);
+ }
start = end;
i = end;
}
@@ -46,6 +60,10 @@ pub fn escape_assertion(s: &str) -> String {
res
}
+pub fn escape_in_operator(m: &str) -> Cow<'_, str> {
+ ESC_IN.replace_all(m, "in [${1}]")
+}
+
pub fn remove_comment(s: &str) -> String {
let s = if let Some(idx) = s.find('#') {
&s[..idx]
@@ -117,7 +135,7 @@ mod tests {
)
);
assert_eq!(
- r#"r_obj == "a\"p.b" || p_obj == 'r'"#,
+ r#"r_obj == "a\"p.b" || p_obj == "r""#,
escape_assertion(r#"r.obj == "a\"p.b" || p.obj == 'r'"#)
);
}
diff --git a/tests/conformance.rs b/tests/conformance.rs
new file mode 100644
index 0000000..2aa502e
--- /dev/null
+++ b/tests/conformance.rs
@@ -0,0 +1,101 @@
+#![cfg(all(
+ feature = "explain",
+ feature = "runtime-tokio",
+ not(target_arch = "wasm32")
+))]
+
+use casbin::prelude::*;
+use serde::Deserialize;
+use std::{fs, path::PathBuf};
+
+#[derive(Deserialize)]
+struct ConformanceCase {
+ model: String,
+ policy: String,
+ tests: Vec<ConformanceTest>,
+}
+
+#[derive(Deserialize)]
+struct ConformanceTest {
+ request: Vec<String>,
+ allow: bool,
+ explain: Vec<String>,
+}
+
+async fn run_case(dir: PathBuf, file: PathBuf) -> Vec<String> {
+ let name = file.file_name().unwrap().to_string_lossy().to_string();
+ let case: ConformanceCase =
+ serde_json::from_str(&fs::read_to_string(&file).unwrap()).unwrap();
+ let model =
+ DefaultModel::from_file(dir.join("data").join(&case.model)).await;
+ let e = match model {
+ Ok(model) => {
+ Enforcer::new(
+ model,
+ FileAdapter::new(dir.join("data").join(&case.policy)),
+ )
+ .await
+ }
+ Err(err) => Err(err),
+ };
+ let e = match e {
+ Ok(e) => e,
+ Err(err) => return vec![format!("{}: {}", name, err)],
+ };
+
+ let mut failures = Vec::new();
+ for test in case.tests {
+ match e.enforce_ex(test.request.clone()) {
+ Ok((allow, rules)) => {
+ let explain = rules.into_iter().next().unwrap_or_default();
+ if allow != test.allow || explain != test.explain {
+ failures.push(format!(
+ "{} {:?}: got ({}, {:?}), want ({}, {:?})",
+ name,
+ test.request,
+ allow,
+ explain,
+ test.allow,
+ test.explain
+ ));
+ }
+ }
+ Err(err) => {
+ failures.push(format!("{} {:?}: {}", name, test.request, err))
+ }
+ }
+ }
+ failures
+}
+
+#[tokio::test]
+async fn test_conformance() {
+ let dir = match std::env::var("CASBIN_CONFORMANCE_DIR") {
+ Ok(dir) if !dir.is_empty() => PathBuf::from(dir),
+ _ => return,
+ };
+
+ let mut files: Vec<_> = fs::read_dir(dir.join("cases"))
+ .unwrap()
+ .map(|entry| entry.unwrap().path())
+ .filter(|path| path.extension().map_or(false, |ext| ext == "json"))
+ .collect();
+ files.sort();
+ assert!(!files.is_empty(), "no conformance cases found in {:?}", dir);
+
+ let mut failures = Vec::new();
+ for file in files {
+ let name = file.file_name().unwrap().to_string_lossy().to_string();
+ match tokio::spawn(run_case(dir.clone(), file)).await {
+ Ok(case_failures) => failures.extend(case_failures),
+ Err(err) => failures.push(format!("{}: {}", name, err)),
+ }
+ }
+
+ assert!(
+ failures.is_empty(),
+ "{} conformance failures:\n{}",
+ failures.len(),
+ failures.join("\n")
+ );
+}
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]