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