Skip to content

Commit 8a8b702

Browse files
committed
feat: add CachedEnforcer, SyncedCachedEnforcer and AsyncCachedEnforcer
1 parent 9e65fac commit 8a8b702

10 files changed

Lines changed: 1421 additions & 1 deletion

‎casbin/__init__.py‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,10 @@
1717
from .distributed_enforcer import DistributedEnforcer
1818
from .fast_enforcer import FastEnforcer
1919
from .async_enforcer import AsyncEnforcer
20+
from .cached_enforcer import CachedEnforcer
21+
from .synced_cached_enforcer import SyncedCachedEnforcer
22+
from .async_cached_enforcer import AsyncCachedEnforcer
23+
from .cache import Cache, DefaultCache
2024
from . import util
2125
from .persist import *
2226
from .effect import *

‎casbin/async_cached_enforcer.py‎

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
# Copyright 2026 The casbin Authors. All Rights Reserved.
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
from casbin.async_enforcer import AsyncEnforcer
16+
from casbin.cached_enforcer import CacheSupport
17+
18+
19+
class AsyncCachedEnforcer(CacheSupport, AsyncEnforcer):
20+
"""AsyncEnforcer that caches enforce() decisions.
21+
22+
Only policy management is asynchronous upstream; enforce()/enforce_ex() stay
23+
synchronous, and so does the caching around them. The methods overridden here are
24+
exactly the mutating ones AsyncEnforcer defines as coroutines -- the synchronous ones
25+
are handled by CacheSupport, and redeclaring them as coroutines here would turn calls
26+
like ``e.clear_policy()`` into un-awaited no-ops.
27+
28+
See CachedEnforcer for what the cache does and does not notice.
29+
"""
30+
31+
async def load_policy(self):
32+
try:
33+
return await super().load_policy()
34+
finally:
35+
self.invalidate_cache()
36+
37+
async def load_filtered_policy(self, filter):
38+
try:
39+
return await super().load_filtered_policy(filter)
40+
finally:
41+
self.invalidate_cache()
42+
43+
async def load_increment_filtered_policy(self, filter):
44+
try:
45+
return await super().load_increment_filtered_policy(filter)
46+
finally:
47+
self.invalidate_cache()
48+
49+
async def _add_policy(self, sec, ptype, rule):
50+
try:
51+
return await super()._add_policy(sec, ptype, rule)
52+
finally:
53+
self.invalidate_cache()
54+
55+
async def _add_policies(self, sec, ptype, rules):
56+
try:
57+
return await super()._add_policies(sec, ptype, rules)
58+
finally:
59+
self.invalidate_cache()
60+
61+
async def _update_policy(self, sec, ptype, old_rule, new_rule):
62+
try:
63+
return await super()._update_policy(sec, ptype, old_rule, new_rule)
64+
finally:
65+
self.invalidate_cache()
66+
67+
async def _update_policies(self, sec, ptype, old_rules, new_rules):
68+
try:
69+
return await super()._update_policies(sec, ptype, old_rules, new_rules)
70+
finally:
71+
self.invalidate_cache()
72+
73+
async def _update_filtered_policies(self, sec, ptype, new_rules, field_index, *field_values):
74+
try:
75+
return await super()._update_filtered_policies(sec, ptype, new_rules, field_index, *field_values)
76+
finally:
77+
self.invalidate_cache()
78+
79+
async def _remove_policy(self, sec, ptype, rule):
80+
try:
81+
return await super()._remove_policy(sec, ptype, rule)
82+
finally:
83+
self.invalidate_cache()
84+
85+
async def _remove_policies(self, sec, ptype, rules):
86+
try:
87+
return await super()._remove_policies(sec, ptype, rules)
88+
finally:
89+
self.invalidate_cache()
90+
91+
async def _remove_filtered_policy(self, sec, ptype, field_index, *field_values):
92+
try:
93+
return await super()._remove_filtered_policy(sec, ptype, field_index, *field_values)
94+
finally:
95+
self.invalidate_cache()
96+
97+
async def _remove_filtered_policy_returns_effects(self, sec, ptype, field_index, *field_values):
98+
try:
99+
return await super()._remove_filtered_policy_returns_effects(sec, ptype, field_index, *field_values)
100+
finally:
101+
self.invalidate_cache()

‎casbin/cache/__init__.py‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
# Copyright 2026 The casbin Authors. All Rights Reserved.
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
from .cache import Cache
16+
from .default_cache import DefaultCache, DEFAULT_MAX_SIZE
17+
18+
__all__ = ["Cache", "DefaultCache", "DEFAULT_MAX_SIZE"]

‎casbin/cache/cache.py‎

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
# Copyright 2026 The casbin Authors. All Rights Reserved.
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
from abc import ABC, abstractmethod
16+
17+
18+
class Cache(ABC):
19+
"""the interface a cache backend has to implement to be usable by CachedEnforcer.
20+
21+
A miss is reported by returning the ``default`` object handed to ``get``, never by
22+
raising. The enforcer passes a private sentinel as ``default``, so an implementation
23+
that returns it verbatim on a miss can cache ``False`` and ``None`` values safely.
24+
25+
Implementations are expected to be safe to call from several threads at once, since
26+
a single enforcer may be shared between them.
27+
"""
28+
29+
@abstractmethod
30+
def get(self, key, default=None):
31+
"""returns the value stored under key, or default if key is absent or expired."""
32+
33+
@abstractmethod
34+
def set(self, key, value, ttl=None):
35+
"""stores value under key.
36+
37+
ttl is the number of seconds the entry stays valid; None means it never expires
38+
on its own.
39+
"""
40+
41+
@abstractmethod
42+
def delete(self, key):
43+
"""drops a single key, if present."""
44+
45+
@abstractmethod
46+
def clear(self):
47+
"""drops every entry."""

‎casbin/cache/default_cache.py‎

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
# Copyright 2026 The casbin Authors. All Rights Reserved.
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
import threading
16+
import time
17+
from collections import OrderedDict
18+
19+
from .cache import Cache
20+
21+
DEFAULT_MAX_SIZE = 10000
22+
23+
24+
class DefaultCache(Cache):
25+
"""an in-memory cache with LRU eviction and optional per-entry expiry.
26+
27+
The size bound matters here: cache keys are built from request values such as user
28+
names, so an unbounded cache in front of enforce() grows with the number of distinct
29+
users an application has ever seen. Once max_size entries are stored, the least
30+
recently used one is evicted.
31+
32+
All operations hold a lock, so the cache can back an enforcer shared between threads.
33+
"""
34+
35+
def __init__(self, max_size=DEFAULT_MAX_SIZE):
36+
if max_size is not None and max_size <= 0:
37+
raise ValueError("max_size must be a positive integer or None for no bound")
38+
self.max_size = max_size
39+
self._lock = threading.Lock()
40+
self._store = OrderedDict()
41+
42+
def get(self, key, default=None):
43+
with self._lock:
44+
entry = self._store.get(key)
45+
if entry is None:
46+
return default
47+
48+
value, expires_at = entry
49+
if expires_at is not None and time.monotonic() >= expires_at:
50+
# only this thread can reach the delete: the lookup and the delete happen
51+
# under the same lock acquisition.
52+
del self._store[key]
53+
return default
54+
55+
self._store.move_to_end(key)
56+
return value
57+
58+
def set(self, key, value, ttl=None):
59+
expires_at = time.monotonic() + ttl if ttl is not None and ttl > 0 else None
60+
with self._lock:
61+
self._store[key] = (value, expires_at)
62+
self._store.move_to_end(key)
63+
if self.max_size is not None:
64+
while len(self._store) > self.max_size:
65+
self._store.popitem(last=False)
66+
67+
def delete(self, key):
68+
with self._lock:
69+
self._store.pop(key, None)
70+
71+
def clear(self):
72+
with self._lock:
73+
self._store.clear()
74+
75+
def __len__(self):
76+
with self._lock:
77+
return len(self._store)

0 commit comments

Comments
 (0)