You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@casbin.apache.org by hs...@apache.org on 2026/08/06 08:46:16 UTC
(casbin-pycasbin) branch master updated: feat: add CachedEnforcer, SyncedCachedEnforcer and AsyncCachedEnforcer
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-pycasbin.git
The following commit(s) were added to refs/heads/master by this push:
new 8a8b702 feat: add CachedEnforcer, SyncedCachedEnforcer and AsyncCachedEnforcer
8a8b702 is described below
commit 8a8b70291999e04464434bde881e0e662a3bb787
Author: Yang Luo <hs...@gmail.com>
AuthorDate: Thu Aug 6 16:46:08 2026 +0800
feat: add CachedEnforcer, SyncedCachedEnforcer and AsyncCachedEnforcer
---
casbin/__init__.py | 4 +
casbin/async_cached_enforcer.py | 101 ++++++
casbin/{ => cache}/__init__.py | 16 +-
casbin/cache/cache.py | 47 +++
casbin/cache/default_cache.py | 77 ++++
casbin/cached_enforcer.py | 343 ++++++++++++++++++
casbin/core_enforcer.py | 4 +
casbin/synced_cached_enforcer.py | 63 ++++
casbin/synced_enforcer.py | 8 +-
tests/test_cached_enforcer.py | 757 +++++++++++++++++++++++++++++++++++++++
10 files changed, 1408 insertions(+), 12 deletions(-)
diff --git a/casbin/__init__.py b/casbin/__init__.py
index fa81258..9c2719e 100644
--- a/casbin/__init__.py
+++ b/casbin/__init__.py
@@ -17,6 +17,10 @@ from .synced_enforcer import SyncedEnforcer
from .distributed_enforcer import DistributedEnforcer
from .fast_enforcer import FastEnforcer
from .async_enforcer import AsyncEnforcer
+from .cached_enforcer import CachedEnforcer
+from .synced_cached_enforcer import SyncedCachedEnforcer
+from .async_cached_enforcer import AsyncCachedEnforcer
+from .cache import Cache, DefaultCache
from . import util
from .persist import *
from .effect import *
diff --git a/casbin/async_cached_enforcer.py b/casbin/async_cached_enforcer.py
new file mode 100644
index 0000000..c44bdee
--- /dev/null
+++ b/casbin/async_cached_enforcer.py
@@ -0,0 +1,101 @@
+# Copyright 2026 The casbin Authors. All Rights Reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+from casbin.async_enforcer import AsyncEnforcer
+from casbin.cached_enforcer import CacheSupport
+
+
+class AsyncCachedEnforcer(CacheSupport, AsyncEnforcer):
+ """AsyncEnforcer that caches enforce() decisions.
+
+ Only policy management is asynchronous upstream; enforce()/enforce_ex() stay
+ synchronous, and so does the caching around them. The methods overridden here are
+ exactly the mutating ones AsyncEnforcer defines as coroutines -- the synchronous ones
+ are handled by CacheSupport, and redeclaring them as coroutines here would turn calls
+ like ``e.clear_policy()`` into un-awaited no-ops.
+
+ See CachedEnforcer for what the cache does and does not notice.
+ """
+
+ async def load_policy(self):
+ try:
+ return await super().load_policy()
+ finally:
+ self.invalidate_cache()
+
+ async def load_filtered_policy(self, filter):
+ try:
+ return await super().load_filtered_policy(filter)
+ finally:
+ self.invalidate_cache()
+
+ async def load_increment_filtered_policy(self, filter):
+ try:
+ return await super().load_increment_filtered_policy(filter)
+ finally:
+ self.invalidate_cache()
+
+ async def _add_policy(self, sec, ptype, rule):
+ try:
+ return await super()._add_policy(sec, ptype, rule)
+ finally:
+ self.invalidate_cache()
+
+ async def _add_policies(self, sec, ptype, rules):
+ try:
+ return await super()._add_policies(sec, ptype, rules)
+ finally:
+ self.invalidate_cache()
+
+ async def _update_policy(self, sec, ptype, old_rule, new_rule):
+ try:
+ return await super()._update_policy(sec, ptype, old_rule, new_rule)
+ finally:
+ self.invalidate_cache()
+
+ async def _update_policies(self, sec, ptype, old_rules, new_rules):
+ try:
+ return await super()._update_policies(sec, ptype, old_rules, new_rules)
+ finally:
+ self.invalidate_cache()
+
+ async def _update_filtered_policies(self, sec, ptype, new_rules, field_index, *field_values):
+ try:
+ return await super()._update_filtered_policies(sec, ptype, new_rules, field_index, *field_values)
+ finally:
+ self.invalidate_cache()
+
+ async def _remove_policy(self, sec, ptype, rule):
+ try:
+ return await super()._remove_policy(sec, ptype, rule)
+ finally:
+ self.invalidate_cache()
+
+ async def _remove_policies(self, sec, ptype, rules):
+ try:
+ return await super()._remove_policies(sec, ptype, rules)
+ finally:
+ self.invalidate_cache()
+
+ async def _remove_filtered_policy(self, sec, ptype, field_index, *field_values):
+ try:
+ return await super()._remove_filtered_policy(sec, ptype, field_index, *field_values)
+ finally:
+ self.invalidate_cache()
+
+ async def _remove_filtered_policy_returns_effects(self, sec, ptype, field_index, *field_values):
+ try:
+ return await super()._remove_filtered_policy_returns_effects(sec, ptype, field_index, *field_values)
+ finally:
+ self.invalidate_cache()
diff --git a/casbin/__init__.py b/casbin/cache/__init__.py
similarity index 59%
copy from casbin/__init__.py
copy to casbin/cache/__init__.py
index fa81258..a6a64d7 100644
--- a/casbin/__init__.py
+++ b/casbin/cache/__init__.py
@@ -1,4 +1,4 @@
-# Copyright 2021 The casbin Authors. All Rights Reserved.
+# Copyright 2026 The casbin Authors. All Rights Reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
@@ -12,13 +12,7 @@
# See the License for the specific language governing permissions and
# limitations under the License.
-from .enforcer import *
-from .synced_enforcer import SyncedEnforcer
-from .distributed_enforcer import DistributedEnforcer
-from .fast_enforcer import FastEnforcer
-from .async_enforcer import AsyncEnforcer
-from . import util
-from .persist import *
-from .effect import *
-from .model import *
-from .frontend import *
+from .cache import Cache
+from .default_cache import DefaultCache, DEFAULT_MAX_SIZE
+
+__all__ = ["Cache", "DefaultCache", "DEFAULT_MAX_SIZE"]
diff --git a/casbin/cache/cache.py b/casbin/cache/cache.py
new file mode 100644
index 0000000..59bb9be
--- /dev/null
+++ b/casbin/cache/cache.py
@@ -0,0 +1,47 @@
+# Copyright 2026 The casbin Authors. All Rights Reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+from abc import ABC, abstractmethod
+
+
+class Cache(ABC):
+ """the interface a cache backend has to implement to be usable by CachedEnforcer.
+
+ A miss is reported by returning the ``default`` object handed to ``get``, never by
+ raising. The enforcer passes a private sentinel as ``default``, so an implementation
+ that returns it verbatim on a miss can cache ``False`` and ``None`` values safely.
+
+ Implementations are expected to be safe to call from several threads at once, since
+ a single enforcer may be shared between them.
+ """
+
+ @abstractmethod
+ def get(self, key, default=None):
+ """returns the value stored under key, or default if key is absent or expired."""
+
+ @abstractmethod
+ def set(self, key, value, ttl=None):
+ """stores value under key.
+
+ ttl is the number of seconds the entry stays valid; None means it never expires
+ on its own.
+ """
+
+ @abstractmethod
+ def delete(self, key):
+ """drops a single key, if present."""
+
+ @abstractmethod
+ def clear(self):
+ """drops every entry."""
diff --git a/casbin/cache/default_cache.py b/casbin/cache/default_cache.py
new file mode 100644
index 0000000..935d289
--- /dev/null
+++ b/casbin/cache/default_cache.py
@@ -0,0 +1,77 @@
+# Copyright 2026 The casbin Authors. All Rights Reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+import threading
+import time
+from collections import OrderedDict
+
+from .cache import Cache
+
+DEFAULT_MAX_SIZE = 10000
+
+
+class DefaultCache(Cache):
+ """an in-memory cache with LRU eviction and optional per-entry expiry.
+
+ The size bound matters here: cache keys are built from request values such as user
+ names, so an unbounded cache in front of enforce() grows with the number of distinct
+ users an application has ever seen. Once max_size entries are stored, the least
+ recently used one is evicted.
+
+ All operations hold a lock, so the cache can back an enforcer shared between threads.
+ """
+
+ def __init__(self, max_size=DEFAULT_MAX_SIZE):
+ if max_size is not None and max_size <= 0:
+ raise ValueError("max_size must be a positive integer or None for no bound")
+ self.max_size = max_size
+ self._lock = threading.Lock()
+ self._store = OrderedDict()
+
+ def get(self, key, default=None):
+ with self._lock:
+ entry = self._store.get(key)
+ if entry is None:
+ return default
+
+ value, expires_at = entry
+ if expires_at is not None and time.monotonic() >= expires_at:
+ # only this thread can reach the delete: the lookup and the delete happen
+ # under the same lock acquisition.
+ del self._store[key]
+ return default
+
+ self._store.move_to_end(key)
+ return value
+
+ def set(self, key, value, ttl=None):
+ expires_at = time.monotonic() + ttl if ttl is not None and ttl > 0 else None
+ with self._lock:
+ self._store[key] = (value, expires_at)
+ self._store.move_to_end(key)
+ if self.max_size is not None:
+ while len(self._store) > self.max_size:
+ self._store.popitem(last=False)
+
+ def delete(self, key):
+ with self._lock:
+ self._store.pop(key, None)
+
+ def clear(self):
+ with self._lock:
+ self._store.clear()
+
+ def __len__(self):
+ with self._lock:
+ return len(self._store)
diff --git a/casbin/cached_enforcer.py b/casbin/cached_enforcer.py
new file mode 100644
index 0000000..f7a06fb
--- /dev/null
+++ b/casbin/cached_enforcer.py
@@ -0,0 +1,343 @@
+# Copyright 2026 The casbin Authors. All Rights Reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+from casbin.cache import DefaultCache
+from casbin.enforcer import Enforcer
+
+# distinguishes "key absent" from a cached None/False without an exception on the hot path.
+_MISSING = object()
+
+
+class CacheSupport:
+ """the caching half of CachedEnforcer, shared by the sync and async enforcers.
+
+ Mix it in *before* an enforcer class so that ``super()`` inside it reaches the
+ enforcer implementation::
+
+ class CachedEnforcer(CacheSupport, Enforcer):
+ pass
+
+ Only enforce_ex() is intercepted. enforce() and batch_enforce() are defined in terms
+ of it upstream, so they pick the cache up for free and cannot drift out of sync.
+ """
+
+ def __init__(self, *args, **kwargs):
+ # these have to exist before the enforcer constructor runs: it may load a policy,
+ # and the load path invalidates the cache.
+ self._cache = DefaultCache()
+ self._enable_cache = True
+ self._expire_time = None
+ self._warned_about_dynamic_model = False
+ super().__init__(*args, **kwargs)
+
+ def enable_cache(self, enable_cache):
+ """turns the decision cache on or off. Turning it off also drops what is cached,
+ so that turning it back on cannot resurrect stale decisions."""
+ self._enable_cache = enable_cache
+ if not enable_cache:
+ self.invalidate_cache()
+
+ def set_cache(self, cache):
+ """replaces the cache backend with any object implementing casbin.cache.Cache."""
+ self._cache = cache
+
+ def get_cache(self):
+ """returns the cache backend in use."""
+ return self._cache
+
+ def set_expire_time(self, expire_time):
+ """sets how many seconds a cached decision stays valid. None means entries only
+ leave the cache when a policy change invalidates them or the LRU evicts them."""
+ self._expire_time = expire_time
+ self.invalidate_cache()
+
+ def invalidate_cache(self):
+ """drops every cached decision."""
+ self._cache.clear()
+
+ @staticmethod
+ def get_cache_key(*rvals):
+ """builds a hashable key for a request, or returns None if it cannot be cached.
+
+ Values are kept as distinct tuple elements rather than joined into one string, so
+ no combination of separators in the request can make two different requests share
+ a key. Strings map to themselves; numbers and None carry their type name, so that
+ 1, 1.0, True and "1" stay apart. Any other object has to opt in by exposing
+ get_cache_key().
+ """
+ key = []
+ for rval in rvals:
+ if isinstance(rval, str):
+ key.append(rval)
+ elif rval is None or isinstance(rval, (bool, int, float)):
+ key.append((type(rval).__name__, rval))
+ else:
+ get_key = getattr(rval, "get_cache_key", None)
+ if not callable(get_key):
+ return None
+ sub_key = get_key()
+ if sub_key is None:
+ return None
+ key.append((type(rval).__name__, sub_key))
+
+ return tuple(key)
+
+ def _cache_would_be_stale(self):
+ """reports whether caching decisions is unsound for the current model.
+
+ Conditional role managers run a link condition function on every call. Those
+ functions typically close over the wall clock (a role that is valid until 18:00)
+ or over parameters set later through set_named_link_condition_func_params, so a
+ decision is not a pure function of the request and caching it would keep granting
+ access after the role has expired.
+
+ Setting an expire time is treated as accepting a known, bounded amount of
+ staleness, so it re-enables caching.
+ """
+ if not self.cond_rm_map:
+ return False
+ if self._expire_time is not None:
+ return False
+
+ if not self._warned_about_dynamic_model:
+ self._warned_about_dynamic_model = True
+ self.logger.warning(
+ "CachedEnforcer: this model uses conditional role managers, whose link "
+ "conditions are re-evaluated on every request. Caching is bypassed so that "
+ "expired roles stop granting access. Call set_expire_time() to cache anyway "
+ "with a bounded staleness window."
+ )
+ return True
+
+ def enforce_ex(self, *rvals):
+ """decides whether a "subject" can access a "object" with the operation "action",
+ serving the answer from the cache when the same request was decided before.
+ """
+ if not self._enable_cache or self._cache_would_be_stale():
+ return super().enforce_ex(*rvals)
+
+ key = self.get_cache_key(*rvals)
+ if key is None:
+ return super().enforce_ex(*rvals)
+
+ cached = self._cache.get(key, _MISSING)
+ if cached is not _MISSING:
+ result, explain = cached
+ # hand out a copy: the explanation upstream is the policy row itself, and a
+ # caller mutating it would corrupt both the cache and the model.
+ return result, list(explain)
+
+ result, explain = super().enforce_ex(*rvals)
+ self._cache.set(key, (result, tuple(explain)), self._expire_time)
+ return result, explain
+
+ # Everything below invalidates the cache after letting the enforcer do its work.
+ #
+ # Invalidating afterwards rather than before is deliberate: with a pre-invalidation, a
+ # request arriving between the flush and the mutation would re-cache the pre-mutation
+ # answer and keep it indefinitely.
+ #
+ # tests/test_cached_enforcer.py asserts this list still covers every mutating method
+ # the enforcers expose, so an upstream addition cannot silently go uninvalidated.
+
+ def clear_policy(self):
+ try:
+ return super().clear_policy()
+ finally:
+ self.invalidate_cache()
+
+ def init_rm_map(self):
+ try:
+ return super().init_rm_map()
+ finally:
+ self.invalidate_cache()
+
+ def build_role_links(self):
+ try:
+ return super().build_role_links()
+ finally:
+ self.invalidate_cache()
+
+ def set_model(self, m):
+ try:
+ return super().set_model(m)
+ finally:
+ self.invalidate_cache()
+
+ def load_model(self):
+ try:
+ return super().load_model()
+ finally:
+ self.invalidate_cache()
+
+ def set_adapter(self, adapter):
+ try:
+ return super().set_adapter(adapter)
+ finally:
+ self.invalidate_cache()
+
+ def set_role_manager(self, rm):
+ try:
+ return super().set_role_manager(rm)
+ finally:
+ self.invalidate_cache()
+
+ def set_named_role_manager(self, ptype, rm):
+ try:
+ return super().set_named_role_manager(ptype, rm)
+ finally:
+ self.invalidate_cache()
+
+ def set_effector(self, eft):
+ try:
+ return super().set_effector(eft)
+ finally:
+ self.invalidate_cache()
+
+ def add_function(self, name, func):
+ try:
+ return super().add_function(name, func)
+ finally:
+ self.invalidate_cache()
+
+ def enable_enforce(self, enabled=True):
+ try:
+ return super().enable_enforce(enabled)
+ finally:
+ self.invalidate_cache()
+
+ def add_named_matching_func(self, ptype, fn):
+ try:
+ return super().add_named_matching_func(ptype, fn)
+ finally:
+ self.invalidate_cache()
+
+ def add_named_domain_matching_func(self, ptype, fn):
+ try:
+ return super().add_named_domain_matching_func(ptype, fn)
+ finally:
+ self.invalidate_cache()
+
+ def add_named_link_condition_func(self, ptype, user, role, fn):
+ try:
+ return super().add_named_link_condition_func(ptype, user, role, fn)
+ finally:
+ self.invalidate_cache()
+
+ def add_named_domain_link_condition_func(self, ptype, user, role, domain, fn):
+ try:
+ return super().add_named_domain_link_condition_func(ptype, user, role, domain, fn)
+ finally:
+ self.invalidate_cache()
+
+ def set_named_link_condition_func_params(self, ptype, user, role, *params):
+ try:
+ return super().set_named_link_condition_func_params(ptype, user, role, *params)
+ finally:
+ self.invalidate_cache()
+
+ def set_named_domain_link_condition_func_params(self, ptype, user, role, domain, *params):
+ try:
+ return super().set_named_domain_link_condition_func_params(ptype, user, role, domain, *params)
+ finally:
+ self.invalidate_cache()
+
+
+class CachedEnforcer(CacheSupport, Enforcer):
+ """Enforcer that caches enforce() decisions.
+
+ The cache is keyed on the request alone, so it is only correct as long as every change
+ that could alter a decision drops it. Policy changes made through this enforcer do
+ that automatically. Changes made behind its back -- a second process writing to the
+ same database, or code reaching into ``e.model`` directly -- do not, so pair those with
+ a call to invalidate_cache() or set_expire_time().
+ """
+
+ def load_policy(self):
+ try:
+ return super().load_policy()
+ finally:
+ self.invalidate_cache()
+
+ def load_filtered_policy(self, filter):
+ try:
+ return super().load_filtered_policy(filter)
+ finally:
+ self.invalidate_cache()
+
+ def load_increment_filtered_policy(self, filter):
+ try:
+ return super().load_increment_filtered_policy(filter)
+ finally:
+ self.invalidate_cache()
+
+ def _add_policy(self, sec, ptype, rule):
+ try:
+ return super()._add_policy(sec, ptype, rule)
+ finally:
+ self.invalidate_cache()
+
+ def _add_policies(self, sec, ptype, rules):
+ try:
+ return super()._add_policies(sec, ptype, rules)
+ finally:
+ self.invalidate_cache()
+
+ def _add_policies_ex(self, sec, ptype, rules):
+ try:
+ return super()._add_policies_ex(sec, ptype, rules)
+ finally:
+ self.invalidate_cache()
+
+ def _update_policy(self, sec, ptype, old_rule, new_rule):
+ try:
+ return super()._update_policy(sec, ptype, old_rule, new_rule)
+ finally:
+ self.invalidate_cache()
+
+ def _update_policies(self, sec, ptype, old_rules, new_rules):
+ try:
+ return super()._update_policies(sec, ptype, old_rules, new_rules)
+ finally:
+ self.invalidate_cache()
+
+ def _update_filtered_policies(self, sec, ptype, new_rules, field_index, *field_values):
+ try:
+ return super()._update_filtered_policies(sec, ptype, new_rules, field_index, *field_values)
+ finally:
+ self.invalidate_cache()
+
+ def _remove_policy(self, sec, ptype, rule):
+ try:
+ return super()._remove_policy(sec, ptype, rule)
+ finally:
+ self.invalidate_cache()
+
+ def _remove_policies(self, sec, ptype, rules):
+ try:
+ return super()._remove_policies(sec, ptype, rules)
+ finally:
+ self.invalidate_cache()
+
+ def _remove_filtered_policy(self, sec, ptype, field_index, *field_values):
+ try:
+ return super()._remove_filtered_policy(sec, ptype, field_index, *field_values)
+ finally:
+ self.invalidate_cache()
+
+ def _remove_filtered_policy_returns_effects(self, sec, ptype, field_index, *field_values):
+ try:
+ return super()._remove_filtered_policy_returns_effects(sec, ptype, field_index, *field_values)
+ finally:
+ self.invalidate_cache()
diff --git a/casbin/core_enforcer.py b/casbin/core_enforcer.py
index 5b5e0e5..a80744b 100644
--- a/casbin/core_enforcer.py
+++ b/casbin/core_enforcer.py
@@ -36,6 +36,10 @@ class EnforceContext:
self.etype: str = etype
self.mtype: str = mtype
+ def get_cache_key(self):
+ """lets CachedEnforcer key a request that carries an EnforceContext."""
+ return self.rtype, self.ptype, self.etype, self.mtype
+
class CoreEnforcer:
"""CoreEnforcer defines the core functionality of an enforcer."""
diff --git a/casbin/synced_cached_enforcer.py b/casbin/synced_cached_enforcer.py
new file mode 100644
index 0000000..26df23b
--- /dev/null
+++ b/casbin/synced_cached_enforcer.py
@@ -0,0 +1,63 @@
+# Copyright 2026 The casbin Authors. All Rights Reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+from casbin.cached_enforcer import CachedEnforcer
+from casbin.synced_enforcer import SyncedEnforcer
+
+
+class SyncedCachedEnforcer(SyncedEnforcer):
+ """SyncedEnforcer whose underlying enforcer caches enforce() decisions.
+
+ enforce() runs under the read lock, and serving it from the cache also writes to the
+ cache, so several readers can be storing decisions at once. That is safe because the
+ cache does its own locking -- see casbin.cache.DefaultCache. A cache installed through
+ set_cache() has to offer the same guarantee.
+
+ Policy changes take the write lock, and invalidation happens inside that same call, so
+ no reader can observe a mutated policy alongside a pre-mutation cache entry.
+ """
+
+ @staticmethod
+ def _new_enforcer(model, adapter):
+ return CachedEnforcer(model, adapter)
+
+ def enable_cache(self, enable_cache):
+ """turns the decision cache on or off."""
+ with self._wl:
+ return self._e.enable_cache(enable_cache)
+
+ def set_cache(self, cache):
+ """replaces the cache backend. It must be safe to use from several threads."""
+ with self._wl:
+ return self._e.set_cache(cache)
+
+ def get_cache(self):
+ """returns the cache backend in use."""
+ with self._rl:
+ return self._e.get_cache()
+
+ def set_expire_time(self, expire_time):
+ """sets how many seconds a cached decision stays valid."""
+ with self._wl:
+ return self._e.set_expire_time(expire_time)
+
+ def invalidate_cache(self):
+ """drops every cached decision."""
+ with self._wl:
+ return self._e.invalidate_cache()
+
+ @staticmethod
+ def get_cache_key(*rvals):
+ """builds a hashable key for a request, or returns None if it cannot be cached."""
+ return CachedEnforcer.get_cache_key(*rvals)
diff --git a/casbin/synced_enforcer.py b/casbin/synced_enforcer.py
index 207f793..39b11a5 100644
--- a/casbin/synced_enforcer.py
+++ b/casbin/synced_enforcer.py
@@ -40,13 +40,19 @@ class SyncedEnforcer:
It's also a drop-in replacement for Enforcer"""
def __init__(self, model=None, adapter=None):
- self._e = Enforcer(model, adapter)
+ self._e = self._new_enforcer(model, adapter)
self._rwlock = RWLockWrite()
self._rl = self._rwlock.gen_rlock()
self._wl = self._rwlock.gen_wlock()
self._auto_loading = AtomicBool(False)
self._auto_loading_thread = None
+ @staticmethod
+ def _new_enforcer(model, adapter):
+ """builds the enforcer this wrapper guards. Subclasses override it to wrap a
+ different one without duplicating the constructor."""
+ return Enforcer(model, adapter)
+
def is_auto_loading_running(self):
"""check if SyncedEnforcer is auto loading policies"""
return self._auto_loading.value
diff --git a/tests/test_cached_enforcer.py b/tests/test_cached_enforcer.py
new file mode 100644
index 0000000..004bbf6
--- /dev/null
+++ b/tests/test_cached_enforcer.py
@@ -0,0 +1,757 @@
+# Copyright 2026 The casbin Authors. All Rights Reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+import contextlib
+import logging
+import os
+import re
+import threading
+import time
+from unittest import TestCase, IsolatedAsyncioTestCase
+
+import casbin
+from casbin.async_enforcer import AsyncEnforcer
+from casbin.cache import Cache, DefaultCache
+from casbin.cached_enforcer import CacheSupport
+from casbin.core_enforcer import EnforceContext
+from casbin.enforcer import Enforcer
+from casbin.persist.adapters import FilteredFileAdapter
+from casbin.persist.adapters.filtered_file_adapter import Filter
+from casbin.util import time_match_func
+
+
+def get_examples(path):
+ examples_path = os.path.split(os.path.realpath(__file__))[0] + "/../examples/"
+ return os.path.abspath(examples_path + path)
+
+
+@contextlib.contextmanager
+def logging_enabled(name):
+ """casbin silences its loggers unless the caller asks for them, and assertLogs()
+ cannot see records from a disabled logger."""
+ logger = logging.getLogger(name)
+ was_disabled = logger.disabled
+ logger.disabled = False
+ try:
+ yield
+ finally:
+ logger.disabled = was_disabled
+
+
+class TestDefaultCache(TestCase):
+ def test_set_get_delete_clear(self):
+ c = DefaultCache()
+ sentinel = object()
+
+ self.assertIs(c.get("nope", sentinel), sentinel)
+
+ c.set("k", "v")
+ self.assertEqual(c.get("k", sentinel), "v")
+
+ c.delete("k")
+ self.assertIs(c.get("k", sentinel), sentinel)
+
+ c.set("a", 1)
+ c.set("b", 2)
+ c.clear()
+ self.assertEqual(len(c), 0)
+
+ def test_falsy_values_are_not_confused_with_a_miss(self):
+ # a denied decision is False, and reporting it as a miss would turn the cache
+ # into a slow no-op for exactly the requests that matter most.
+ c = DefaultCache()
+ sentinel = object()
+
+ c.set("denied", False)
+ c.set("none", None)
+
+ self.assertIs(c.get("denied", sentinel), False)
+ self.assertIs(c.get("none", sentinel), None)
+
+ def test_entries_expire(self):
+ c = DefaultCache()
+ sentinel = object()
+
+ c.set("k", "v", 0.05)
+ self.assertEqual(c.get("k", sentinel), "v")
+
+ time.sleep(0.1)
+ self.assertIs(c.get("k", sentinel), sentinel)
+ self.assertEqual(len(c), 0)
+
+ def test_ttl_of_none_or_zero_never_expires(self):
+ c = DefaultCache()
+ c.set("a", 1, None)
+ c.set("b", 2, 0)
+ time.sleep(0.05)
+ self.assertEqual(c.get("a"), 1)
+ self.assertEqual(c.get("b"), 2)
+
+ def test_size_is_bounded(self):
+ # keys are built from request values such as user names, so an unbounded cache
+ # would grow with every distinct user the application has ever seen.
+ c = DefaultCache(max_size=10)
+ for i in range(1000):
+ c.set(i, i)
+
+ self.assertEqual(len(c), 10)
+ self.assertEqual(c.get(999), 999)
+ self.assertIsNone(c.get(0))
+
+ def test_least_recently_used_entry_is_evicted(self):
+ c = DefaultCache(max_size=3)
+ c.set("a", 1)
+ c.set("b", 2)
+ c.set("c", 3)
+
+ c.get("a") # "b" is now the least recently used
+ c.set("d", 4)
+
+ self.assertEqual(c.get("a"), 1)
+ self.assertIsNone(c.get("b"))
+ self.assertEqual(c.get("c"), 3)
+ self.assertEqual(c.get("d"), 4)
+
+ def test_unbounded_is_opt_in(self):
+ c = DefaultCache(max_size=None)
+ for i in range(1000):
+ c.set(i, i)
+ self.assertEqual(len(c), 1000)
+
+ def test_rejects_a_meaningless_bound(self):
+ with self.assertRaises(ValueError):
+ DefaultCache(max_size=0)
+ with self.assertRaises(ValueError):
+ DefaultCache(max_size=-1)
+
+ def test_concurrent_access_is_safe(self):
+ c = DefaultCache(max_size=64)
+ errors = []
+
+ def worker(n):
+ try:
+ for i in range(2000):
+ c.set((n, i % 128), i, 0.001)
+ c.get((n, i % 128))
+ except Exception as exc: # pragma: no cover - only runs on a real failure
+ errors.append(exc)
+
+ threads = [threading.Thread(target=worker, args=(n,)) for n in range(8)]
+ for t in threads:
+ t.start()
+ for t in threads:
+ t.join()
+
+ self.assertEqual(errors, [])
+ self.assertLessEqual(len(c), 64)
+
+
+class TestCacheKey(TestCase):
+ def test_plain_request(self):
+ self.assertEqual(
+ casbin.CachedEnforcer.get_cache_key("alice", "data1", "read"),
+ ("alice", "data1", "read"),
+ )
+
+ def test_separators_in_values_cannot_collide(self):
+ # a key built by joining values with a separator would map both of these onto the
+ # same entry, letting one request answer another.
+ for sep in ("$$", ",", "|", "::", ""):
+ a = casbin.CachedEnforcer.get_cache_key("x" + sep + "y", "z")
+ b = casbin.CachedEnforcer.get_cache_key("x", sep + "y", "z")
+ c = casbin.CachedEnforcer.get_cache_key("x", "y" + sep + "z")
+ self.assertEqual(len({a, b, c}), 3, "collision with separator %r" % sep)
+
+ def test_types_are_kept_apart(self):
+ keys = [
+ casbin.CachedEnforcer.get_cache_key(1),
+ casbin.CachedEnforcer.get_cache_key(1.0),
+ casbin.CachedEnforcer.get_cache_key(True),
+ casbin.CachedEnforcer.get_cache_key("1"),
+ casbin.CachedEnforcer.get_cache_key(None),
+ casbin.CachedEnforcer.get_cache_key("None"),
+ ]
+ self.assertEqual(len(set(keys)), len(keys))
+
+ def test_objects_have_to_opt_in(self):
+ class Anonymous:
+ pass
+
+ class Keyed:
+ def get_cache_key(self):
+ return "keyed"
+
+ class Refusing:
+ def get_cache_key(self):
+ return None
+
+ self.assertIsNone(casbin.CachedEnforcer.get_cache_key("alice", Anonymous()))
+ self.assertIsNone(casbin.CachedEnforcer.get_cache_key("alice", Refusing()))
+ self.assertIsNotNone(casbin.CachedEnforcer.get_cache_key("alice", Keyed()))
+
+ def test_enforce_context_is_part_of_the_key(self):
+ a = casbin.CachedEnforcer.get_cache_key(EnforceContext("r", "p", "e", "m"), "alice")
+ b = casbin.CachedEnforcer.get_cache_key(EnforceContext("r2", "p2", "e2", "m2"), "alice")
+ self.assertIsNotNone(a)
+ self.assertNotEqual(a, b)
+
+
+class TestCachedEnforcer(TestCase):
+ def get_enforcer(self, model, policy):
+ return casbin.CachedEnforcer(get_examples(model), get_examples(policy))
+
+ def test_basic(self):
+ e = self.get_enforcer("basic_model.conf", "basic_policy.csv")
+
+ for _ in range(3):
+ self.assertTrue(e.enforce("alice", "data1", "read"))
+ self.assertFalse(e.enforce("alice", "data1", "write"))
+ self.assertFalse(e.enforce("bob", "data1", "read"))
+ self.assertTrue(e.enforce("bob", "data2", "write"))
+
+ def test_rbac(self):
+ e = self.get_enforcer("rbac_model.conf", "rbac_policy.csv")
+
+ for _ in range(3):
+ self.assertTrue(e.enforce("alice", "data1", "read"))
+ self.assertTrue(e.enforce("alice", "data2", "read"))
+ self.assertTrue(e.enforce("alice", "data2", "write"))
+ self.assertFalse(e.enforce("bob", "data1", "read"))
+
+ def test_a_repeated_request_is_served_from_the_cache(self):
+ e = self.get_enforcer("basic_model.conf", "basic_policy.csv")
+ self.assertEqual(len(e.get_cache()), 0)
+
+ e.enforce("alice", "data1", "read")
+ self.assertEqual(len(e.get_cache()), 1)
+
+ e.enforce("alice", "data1", "read")
+ self.assertEqual(len(e.get_cache()), 1)
+
+ e.enforce("alice", "data1", "write")
+ self.assertEqual(len(e.get_cache()), 2)
+
+ def test_batch_enforce_uses_the_cache(self):
+ e = self.get_enforcer("basic_model.conf", "basic_policy.csv")
+ requests = [("alice", "data1", "read"), ("alice", "data1", "write")]
+
+ self.assertEqual(e.batch_enforce(requests), [True, False])
+ self.assertEqual(e.batch_enforce(requests), [True, False])
+ self.assertEqual(len(e.get_cache()), 2)
+
+ def test_enforce_ex_is_cached_and_matches_the_uncached_result(self):
+ e = self.get_enforcer("basic_model.conf", "basic_policy.csv")
+
+ self.assertTupleEqual(e.enforce_ex("alice", "data1", "read"), (True, ["alice", "data1", "read"]))
+ self.assertTupleEqual(e.enforce_ex("alice", "data1", "read"), (True, ["alice", "data1", "read"]))
+ self.assertTupleEqual(e.enforce_ex("alice", "data2", "read"), (False, []))
+ self.assertTupleEqual(e.enforce_ex("alice", "data2", "read"), (False, []))
+
+ def test_a_caller_cannot_corrupt_the_cache_through_the_explanation(self):
+ # the explanation upstream is the policy row itself; handing the same list out
+ # twice would let a caller rewrite both the cache and the model.
+ e = self.get_enforcer("basic_model.conf", "basic_policy.csv")
+
+ _, explain = e.enforce_ex("alice", "data1", "read")
+ explain.append("tampered")
+
+ self.assertTupleEqual(e.enforce_ex("alice", "data1", "read"), (True, ["alice", "data1", "read"]))
+
+ def test_uncacheable_requests_still_work(self):
+ # ABAC requests carry attribute bags that cannot be turned into a key, so they
+ # have to fall through to a normal evaluation rather than fail or be mis-keyed.
+ e = casbin.CachedEnforcer(get_examples("abac_model.conf"))
+
+ self.assertTrue(e.enforce("alice", {"Owner": "alice", "id": "data1"}, "write"))
+ self.assertFalse(e.enforce("alice", {"Owner": "bob", "id": "data1"}, "write"))
+ self.assertTrue(e.enforce("alice", {"Owner": "alice", "id": "data1"}, "write"))
+ self.assertEqual(len(e.get_cache()), 0)
+
+ def test_enable_cache(self):
+ e = self.get_enforcer("basic_model.conf", "basic_policy.csv")
+ e.enforce("alice", "data1", "read")
+
+ e.enable_cache(False)
+ self.assertEqual(len(e.get_cache()), 0)
+
+ e.enforce("alice", "data1", "read")
+ self.assertEqual(len(e.get_cache()), 0)
+
+ e.enable_cache(True)
+ e.enforce("alice", "data1", "read")
+ self.assertEqual(len(e.get_cache()), 1)
+
+ def test_set_cache(self):
+ class CountingCache(DefaultCache):
+ def __init__(self):
+ super().__init__()
+ self.reads = 0
+
+ def get(self, key, default=None):
+ self.reads += 1
+ return super().get(key, default)
+
+ e = self.get_enforcer("basic_model.conf", "basic_policy.csv")
+ cache = CountingCache()
+ e.set_cache(cache)
+
+ e.enforce("alice", "data1", "read")
+ e.enforce("alice", "data1", "read")
+
+ self.assertIs(e.get_cache(), cache)
+ self.assertEqual(cache.reads, 2)
+
+ def test_expire_time(self):
+ e = self.get_enforcer("basic_model.conf", "basic_policy.csv")
+ e.set_expire_time(0.05)
+
+ self.assertTrue(e.enforce("alice", "data1", "read"))
+ self.assertEqual(len(e.get_cache()), 1)
+
+ time.sleep(0.1)
+ self.assertTrue(e.enforce("alice", "data1", "read"))
+
+ def test_a_custom_cache_only_has_to_honour_the_default(self):
+ # third-party backends implement Cache; the enforcer must not depend on anything
+ # beyond "return the default you were given when the key is absent".
+ class DictCache(Cache):
+ def __init__(self):
+ self.store = {}
+
+ def get(self, key, default=None):
+ return self.store.get(key, default)
+
+ def set(self, key, value, ttl=None):
+ self.store[key] = value
+
+ def delete(self, key):
+ self.store.pop(key, None)
+
+ def clear(self):
+ self.store.clear()
+
+ e = self.get_enforcer("basic_model.conf", "basic_policy.csv")
+ e.set_cache(DictCache())
+
+ self.assertTrue(e.enforce("alice", "data1", "read"))
+ self.assertFalse(e.enforce("alice", "data2", "read"))
+ self.assertTrue(e.enforce("alice", "data1", "read"))
+
+ e.remove_policy("alice", "data1", "read")
+ self.assertFalse(e.enforce("alice", "data1", "read"))
+
+
+class TestCacheInvalidation(TestCase):
+ """every one of these would return a stale answer if the matching hook were missing."""
+
+ def get_enforcer(self, model, policy):
+ return casbin.CachedEnforcer(get_examples(model), get_examples(policy))
+
+ def test_add_policy(self):
+ e = self.get_enforcer("basic_model.conf", "basic_policy.csv")
+ self.assertFalse(e.enforce("alice", "data2", "read"))
+ e.add_policy("alice", "data2", "read")
+ self.assertTrue(e.enforce("alice", "data2", "read"))
+
+ def test_remove_policy(self):
+ e = self.get_enforcer("basic_model.conf", "basic_policy.csv")
+ self.assertTrue(e.enforce("alice", "data1", "read"))
+ e.remove_policy("alice", "data1", "read")
+ self.assertFalse(e.enforce("alice", "data1", "read"))
+
+ def test_add_policies(self):
+ e = self.get_enforcer("basic_model.conf", "basic_policy.csv")
+ self.assertFalse(e.enforce("alice", "data2", "read"))
+ e.add_policies([["alice", "data2", "read"], ["alice", "data2", "write"]])
+ self.assertTrue(e.enforce("alice", "data2", "read"))
+
+ def test_add_policies_ex(self):
+ e = self.get_enforcer("basic_model.conf", "basic_policy.csv")
+ self.assertFalse(e.enforce("alice", "data2", "read"))
+ e.add_policies_ex([["alice", "data1", "read"], ["alice", "data2", "read"]])
+ self.assertTrue(e.enforce("alice", "data2", "read"))
+
+ def test_remove_policies(self):
+ e = self.get_enforcer("basic_model.conf", "basic_policy.csv")
+ self.assertTrue(e.enforce("alice", "data1", "read"))
+ self.assertTrue(e.enforce("bob", "data2", "write"))
+ e.remove_policies([["alice", "data1", "read"], ["bob", "data2", "write"]])
+ self.assertFalse(e.enforce("alice", "data1", "read"))
+ self.assertFalse(e.enforce("bob", "data2", "write"))
+
+ def test_update_policy(self):
+ e = self.get_enforcer("basic_model.conf", "basic_policy.csv")
+ e.enable_auto_save(False) # FileAdapter cannot persist updates
+ self.assertTrue(e.enforce("alice", "data1", "read"))
+ e.update_policy(["alice", "data1", "read"], ["alice", "data1", "write"])
+ self.assertFalse(e.enforce("alice", "data1", "read"))
+ self.assertTrue(e.enforce("alice", "data1", "write"))
+
+ def test_update_policies(self):
+ e = self.get_enforcer("basic_model.conf", "basic_policy.csv")
+ e.enable_auto_save(False) # FileAdapter cannot persist updates
+ self.assertTrue(e.enforce("alice", "data1", "read"))
+ e.update_policies(
+ [["alice", "data1", "read"], ["bob", "data2", "write"]],
+ [["alice", "data1", "write"], ["bob", "data2", "read"]],
+ )
+ self.assertFalse(e.enforce("alice", "data1", "read"))
+ self.assertTrue(e.enforce("alice", "data1", "write"))
+
+ def test_remove_filtered_policy(self):
+ e = self.get_enforcer("basic_model.conf", "basic_policy.csv")
+ self.assertTrue(e.enforce("alice", "data1", "read"))
+ e.remove_filtered_policy(0, "alice")
+ self.assertFalse(e.enforce("alice", "data1", "read"))
+
+ def test_add_grouping_policy(self):
+ e = self.get_enforcer("rbac_model.conf", "rbac_policy.csv")
+ self.assertFalse(e.enforce("bob", "data2", "read"))
+ e.add_grouping_policy("bob", "data2_admin")
+ self.assertTrue(e.enforce("bob", "data2", "read"))
+
+ def test_remove_grouping_policy(self):
+ e = self.get_enforcer("rbac_model.conf", "rbac_policy.csv")
+ self.assertTrue(e.enforce("alice", "data2", "read"))
+ e.remove_grouping_policy("alice", "data2_admin")
+ self.assertFalse(e.enforce("alice", "data2", "read"))
+
+ def test_rbac_api_role_changes(self):
+ e = self.get_enforcer("rbac_model.conf", "rbac_policy.csv")
+ self.assertFalse(e.enforce("bob", "data2", "read"))
+ e.add_role_for_user("bob", "data2_admin")
+ self.assertTrue(e.enforce("bob", "data2", "read"))
+ e.delete_role_for_user("bob", "data2_admin")
+ self.assertFalse(e.enforce("bob", "data2", "read"))
+
+ def test_clear_policy(self):
+ e = self.get_enforcer("rbac_model.conf", "rbac_policy.csv")
+ self.assertTrue(e.enforce("alice", "data1", "read"))
+ e.clear_policy()
+ self.assertFalse(e.enforce("alice", "data1", "read"))
+
+ def test_load_policy(self):
+ e = self.get_enforcer("basic_model.conf", "basic_policy.csv")
+ self.assertTrue(e.enforce("alice", "data1", "read"))
+
+ e.clear_policy()
+ e.add_policy("alice", "data2", "read")
+ self.assertTrue(e.enforce("alice", "data2", "read"))
+
+ e.load_policy()
+ self.assertTrue(e.enforce("alice", "data1", "read"))
+ self.assertFalse(e.enforce("alice", "data2", "read"))
+
+ def test_load_filtered_policy(self):
+ e = casbin.CachedEnforcer(
+ get_examples("rbac_with_domains_model.conf"),
+ FilteredFileAdapter(get_examples("rbac_with_domains_policy.csv")),
+ )
+
+ first = Filter()
+ first.P = ["", "domain1"]
+ first.G = ["", "", "domain1"]
+ e.load_filtered_policy(first)
+ self.assertTrue(e.enforce("alice", "domain1", "data1", "read"))
+
+ second = Filter()
+ second.P = ["", "domain2"]
+ second.G = ["", "", "domain2"]
+ e.load_filtered_policy(second)
+ self.assertFalse(e.enforce("alice", "domain1", "data1", "read"))
+ self.assertTrue(e.enforce("bob", "domain2", "data2", "read"))
+
+ def test_load_increment_filtered_policy(self):
+ e = casbin.CachedEnforcer(
+ get_examples("rbac_with_domains_model.conf"),
+ FilteredFileAdapter(get_examples("rbac_with_domains_policy.csv")),
+ )
+
+ first = Filter()
+ first.P = ["", "domain1"]
+ first.G = ["", "", "domain1"]
+ e.load_filtered_policy(first)
+ self.assertFalse(e.enforce("bob", "domain2", "data2", "read"))
+
+ second = Filter()
+ second.P = ["", "domain2"]
+ second.G = ["", "", "domain2"]
+ e.load_increment_filtered_policy(second)
+ self.assertTrue(e.enforce("bob", "domain2", "data2", "read"))
+
+ def test_build_role_links(self):
+ e = self.get_enforcer("rbac_model.conf", "rbac_policy.csv")
+ self.assertTrue(e.enforce("alice", "data2", "read"))
+
+ e.get_role_manager().clear()
+ e.build_role_links()
+ self.assertTrue(e.enforce("alice", "data2", "read"))
+
+ def test_enable_enforce(self):
+ e = self.get_enforcer("basic_model.conf", "basic_policy.csv")
+ self.assertFalse(e.enforce("alice", "data2", "read"))
+
+ e.enable_enforce(False)
+ self.assertTrue(e.enforce("alice", "data2", "read"))
+
+ e.enable_enforce(True)
+ self.assertFalse(e.enforce("alice", "data2", "read"))
+
+ def test_add_named_matching_func(self):
+ e = casbin.CachedEnforcer(
+ get_examples("rbac_with_pattern_model.conf"),
+ get_examples("rbac_with_pattern_policy.csv"),
+ )
+ self.assertFalse(e.enforce("alice", "/book/1", "GET"))
+
+ e.add_named_matching_func("g2", casbin.util.key_match2_func)
+ e.build_role_links()
+ self.assertTrue(e.enforce("alice", "/book/1", "GET"))
+
+ def test_invalidate_cache(self):
+ e = self.get_enforcer("basic_model.conf", "basic_policy.csv")
+ e.enforce("alice", "data1", "read")
+ self.assertEqual(len(e.get_cache()), 1)
+
+ e.invalidate_cache()
+ self.assertEqual(len(e.get_cache()), 0)
+
+ def test_every_policy_mutating_method_is_intercepted(self):
+ """guards the invalidation list against upstream additions.
+
+ A mutating method that the cache layer does not wrap keeps serving decisions from
+ before the change. That is how load_filtered_policy was missed, so assert the
+ coverage rather than trusting a hand-maintained list.
+ """
+ mutating = re.compile(
+ r"^(_add_polic|_update_polic|_update_filtered|_remove_polic|_remove_filtered"
+ r"|load_policy|load_filtered_policy|load_increment|clear_policy)"
+ )
+
+ for cached_cls, base_cls in ((casbin.CachedEnforcer, Enforcer), (casbin.AsyncCachedEnforcer, AsyncEnforcer)):
+ declared = set()
+ for klass in base_cls.__mro__:
+ declared |= set(vars(klass))
+
+ expected = {name for name in declared if mutating.match(name)}
+ self.assertTrue(expected, "the detection pattern matched nothing on %s" % base_cls.__name__)
+
+ intercepted = set(vars(CacheSupport)) | set(vars(cached_cls))
+ self.assertEqual(
+ set(),
+ expected - intercepted,
+ "%s does not invalidate the cache on: %s" % (cached_cls.__name__, sorted(expected - intercepted)),
+ )
+
+ def test_synchronous_methods_are_not_redeclared_as_coroutines(self):
+ # clear_policy is synchronous even on AsyncEnforcer; making it a coroutine here
+ # would turn e.clear_policy() into an un-awaited no-op.
+ import inspect
+
+ for name in ("clear_policy", "build_role_links", "init_rm_map", "enable_enforce"):
+ self.assertFalse(
+ inspect.iscoroutinefunction(getattr(casbin.AsyncCachedEnforcer, name)),
+ "%s must stay synchronous" % name,
+ )
+
+
+class TestConditionalRolesAreNeverCached(TestCase):
+ """conditional links are re-evaluated per request, so caching them would keep
+ granting access after a role expires. These are the regression tests for that."""
+
+ def get_enforcer(self, cls=casbin.CachedEnforcer):
+ e = cls(
+ get_examples("rbac_with_temporal_roles_model.conf"),
+ get_examples("rbac_with_temporal_roles_policy.csv"),
+ )
+ e.add_named_link_condition_func("g", "alice", "data2_admin", time_match_func)
+ return e
+
+ def test_changing_the_condition_parameters_takes_effect(self):
+ e = self.get_enforcer()
+
+ e.set_named_link_condition_func_params(
+ "g", "alice", "data2_admin", "2000-01-01 00:00:00", "2000-01-02 00:00:00"
+ )
+ self.assertFalse(e.enforce("alice", "data2", "read"))
+
+ e.set_named_link_condition_func_params(
+ "g", "alice", "data2_admin", "2000-01-01 00:00:00", "2100-01-01 00:00:00"
+ )
+ self.assertTrue(e.enforce("alice", "data2", "read"))
+
+ def test_a_role_that_expires_stops_granting_access(self):
+ e = self.get_enforcer()
+
+ now = time.time()
+ e.set_named_link_condition_func_params(
+ "g",
+ "alice",
+ "data2_admin",
+ time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(now - 1)),
+ time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(now + 1)),
+ )
+
+ self.assertTrue(e.enforce("alice", "data2", "read"))
+ time.sleep(2.5)
+ self.assertFalse(e.enforce("alice", "data2", "read"))
+
+ def test_the_decision_cache_warns_and_steps_aside(self):
+ e = self.get_enforcer(casbin.CachedEnforcer)
+ e.set_named_link_condition_func_params(
+ "g", "alice", "data2_admin", "2000-01-01 00:00:00", "2100-01-01 00:00:00"
+ )
+
+ with logging_enabled("casbin.enforcer"), self.assertLogs("casbin.enforcer", level="WARNING") as logs:
+ self.assertTrue(e.enforce("alice", "data2", "read"))
+
+ self.assertTrue(any("conditional role managers" in line for line in logs.output))
+ self.assertEqual(len(e.get_cache()), 0)
+
+ def test_an_explicit_expire_time_opts_back_in(self):
+ e = self.get_enforcer(casbin.CachedEnforcer)
+ e.set_named_link_condition_func_params(
+ "g", "alice", "data2_admin", "2000-01-01 00:00:00", "2100-01-01 00:00:00"
+ )
+ e.set_expire_time(30)
+
+ self.assertTrue(e.enforce("alice", "data2", "read"))
+ self.assertEqual(len(e.get_cache()), 1)
+
+
+class TestSyncedCachedEnforcer(TestCase):
+ def get_enforcer(self, model, policy):
+ return casbin.SyncedCachedEnforcer(get_examples(model), get_examples(policy))
+
+ def test_basic(self):
+ e = self.get_enforcer("basic_model.conf", "basic_policy.csv")
+
+ for _ in range(3):
+ self.assertTrue(e.enforce("alice", "data1", "read"))
+ self.assertFalse(e.enforce("alice", "data2", "read"))
+ self.assertTrue(e.enforce("bob", "data2", "write"))
+
+ def test_invalidation(self):
+ e = self.get_enforcer("basic_model.conf", "basic_policy.csv")
+
+ self.assertTrue(e.enforce("alice", "data1", "read"))
+ e.remove_policy("alice", "data1", "read")
+ self.assertFalse(e.enforce("alice", "data1", "read"))
+
+ e.add_policy("alice", "data1", "read")
+ self.assertTrue(e.enforce("alice", "data1", "read"))
+
+ def test_cache_controls(self):
+ e = self.get_enforcer("basic_model.conf", "basic_policy.csv")
+
+ e.enforce("alice", "data1", "read")
+ e.invalidate_cache()
+ self.assertEqual(len(e.get_cache()), 0)
+
+ e.enable_cache(False)
+ e.enforce("alice", "data1", "read")
+ self.assertEqual(len(e.get_cache()), 0)
+
+ e.enable_cache(True)
+ e.set_expire_time(30)
+ e.enforce("alice", "data1", "read")
+ self.assertEqual(len(e.get_cache()), 1)
+
+ def test_readers_write_to_the_cache_concurrently(self):
+ # enforce() runs under the read lock and populating the cache is a write, so
+ # several readers store decisions at once. Only the cache's own lock stops that
+ # from corrupting it.
+ e = self.get_enforcer("rbac_model.conf", "rbac_policy.csv")
+ errors = []
+ stop = threading.Event()
+
+ def reader():
+ try:
+ while not stop.is_set():
+ for i in range(50):
+ e.enforce("user%d" % i, "data1", "read")
+ self.assertTrue(e.enforce("alice", "data1", "read"))
+ except Exception as exc: # pragma: no cover - only runs on a real failure
+ errors.append(exc)
+
+ def writer():
+ try:
+ for i in range(200):
+ e.add_policy("tmp%d" % i, "data1", "read")
+ e.remove_policy("tmp%d" % i, "data1", "read")
+ except Exception as exc: # pragma: no cover - only runs on a real failure
+ errors.append(exc)
+ finally:
+ stop.set()
+
+ threads = [threading.Thread(target=reader) for _ in range(4)] + [threading.Thread(target=writer)]
+ for t in threads:
+ t.start()
+ for t in threads:
+ t.join(timeout=60)
+
+ self.assertEqual(errors, [])
+ self.assertTrue(e.enforce("alice", "data1", "read"))
+ self.assertFalse(e.enforce("tmp0", "data1", "read"))
+
+
+class TestAsyncCachedEnforcer(IsolatedAsyncioTestCase):
+ async def get_enforcer(self, model, policy):
+ e = casbin.AsyncCachedEnforcer(get_examples(model), get_examples(policy))
+ await e.load_policy()
+ return e
+
+ async def test_basic(self):
+ e = await self.get_enforcer("basic_model.conf", "basic_policy.csv")
+
+ for _ in range(3):
+ self.assertTrue(e.enforce("alice", "data1", "read"))
+ self.assertFalse(e.enforce("alice", "data2", "read"))
+ self.assertTrue(e.enforce("bob", "data2", "write"))
+
+ self.assertEqual(len(e.get_cache()), 3)
+
+ async def test_add_and_remove_policy_invalidate(self):
+ e = await self.get_enforcer("basic_model.conf", "basic_policy.csv")
+
+ self.assertFalse(e.enforce("alice", "data2", "read"))
+ await e.add_policy("alice", "data2", "read")
+ self.assertTrue(e.enforce("alice", "data2", "read"))
+
+ await e.remove_policy("alice", "data2", "read")
+ self.assertFalse(e.enforce("alice", "data2", "read"))
+
+ async def test_grouping_policy_invalidates(self):
+ e = await self.get_enforcer("rbac_model.conf", "rbac_policy.csv")
+
+ self.assertFalse(e.enforce("bob", "data2", "read"))
+ await e.add_grouping_policy("bob", "data2_admin")
+ self.assertTrue(e.enforce("bob", "data2", "read"))
+
+ async def test_load_policy_invalidates(self):
+ e = await self.get_enforcer("basic_model.conf", "basic_policy.csv")
+
+ self.assertTrue(e.enforce("alice", "data1", "read"))
+ e.clear_policy()
+ self.assertFalse(e.enforce("alice", "data1", "read"))
+
+ await e.load_policy()
+ self.assertTrue(e.enforce("alice", "data1", "read"))
+
+ async def test_clear_policy_is_synchronous(self):
+ e = await self.get_enforcer("basic_model.conf", "basic_policy.csv")
+
+ result = e.clear_policy()
+ self.assertIsNone(result)
+ self.assertFalse(e.enforce("alice", "data1", "read"))
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@casbin.apache.org
For additional commands, e-mail: commits-help@casbin.apache.org