Skip to content

Commit 125894f

Browse files
authored
feat: Policy cache invalidation approach (openedx#140)
1 parent 21a847c commit 125894f

14 files changed

Lines changed: 297 additions & 20 deletions

File tree

CHANGELOG.rst

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,15 @@ Unreleased
1616

1717
*
1818

19+
0.19.0 - 2025-11-18
20+
********************
21+
22+
Added
23+
=====
24+
25+
* Handle cache invalidation via a uuid in the database to ensure policy reloads
26+
occur only when necessary.
27+
1928
0.18.0 - 2025-11-17
2029
********************
2130

openedx_authz/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,6 @@
44

55
import os
66

7-
__version__ = "0.18.0"
7+
__version__ = "0.19.0"
88

99
ROOT_DIRECTORY = os.path.dirname(os.path.abspath(__file__))

openedx_authz/api/roles.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -219,6 +219,9 @@ def assign_role_to_subject_in_scope(subject: SubjectData, role: RoleData, scope:
219219
)
220220
if not extended_rule:
221221
raise Exception("Failed to create ExtendedCasbinRule for the assignment")
222+
223+
# Invalidate policy cache to ensure changes are picked up
224+
AuthzEnforcer.invalidate_policy_cache()
222225
return True
223226

224227

@@ -245,7 +248,12 @@ def unassign_role_from_subject_in_scope(subject: SubjectData, role: RoleData, sc
245248
bool: True if the role was unassigned successfully, False otherwise.
246249
"""
247250
enforcer = AuthzEnforcer.get_enforcer()
248-
return enforcer.delete_roles_for_user_in_domain(subject.namespaced_key, role.namespaced_key, scope.namespaced_key)
251+
success = enforcer.delete_roles_for_user_in_domain(
252+
subject.namespaced_key, role.namespaced_key, scope.namespaced_key
253+
)
254+
# Invalidate policy cache to ensure changes are picked up
255+
AuthzEnforcer.invalidate_policy_cache()
256+
return success
249257

250258

251259
def batch_unassign_role_from_subjects_in_scope(subjects: list[SubjectData], role: RoleData, scope: ScopeData) -> None:

openedx_authz/engine/enforcer.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,12 +16,14 @@
1616
"""
1717

1818
import logging
19+
from uuid import uuid4
1920

2021
from casbin import SyncedEnforcer
2122
from casbin_adapter.enforcer import initialize_enforcer
2223
from django.conf import settings
2324

2425
from openedx_authz.engine.adapter import ExtendedAdapter
26+
from openedx_authz.models.engine import PolicyCacheControl
2527

2628

2729
def libraries_v2_enabled() -> bool:
@@ -68,6 +70,7 @@ class AuthzEnforcer:
6870

6971
_enforcer = None
7072
_adapter = None
73+
_last_policy_loaded_version = None
7174

7275
def __new__(cls):
7376
"""Singleton pattern to ensure a single enforcer instance."""
@@ -153,6 +156,45 @@ def configure_enforcer_auto_save_and_load(cls):
153156

154157
cls.configure_enforcer_auto_save(auto_save_policy)
155158

159+
@classmethod
160+
def load_policy_if_needed(cls):
161+
"""Load policy if the last load version indicates it's needed.
162+
163+
This method checks if the policy needs to be reloaded comparing
164+
the last load version with the version in the cache invalidation model,
165+
and reloads it if necessary.
166+
167+
Returns:
168+
None
169+
"""
170+
last_version = PolicyCacheControl.get_version()
171+
172+
if last_version is None:
173+
# No version in cache control; initialize it
174+
last_version = uuid4()
175+
PolicyCacheControl.set_version(last_version)
176+
logger.info("Initialized policy last modified version in cache control.")
177+
178+
if cls._last_policy_loaded_version is None or last_version != cls._last_policy_loaded_version:
179+
# Policy has been modified since last load; reload it
180+
cls._enforcer.load_policy()
181+
cls._last_policy_loaded_version = last_version
182+
logger.info(f"Reloaded policy to version {last_version}")
183+
184+
@classmethod
185+
def invalidate_policy_cache(cls):
186+
"""Invalidate the current policy cache to force a reload on next check.
187+
188+
This method updates the last modified version in the cache invalidation model
189+
to a new UUID, indicating that the policy has changed.
190+
191+
Returns:
192+
None
193+
"""
194+
new_version = uuid4()
195+
PolicyCacheControl.set_version(new_version)
196+
logger.info(f"Invalidated policy cache to version {new_version}")
197+
156198
@classmethod
157199
def get_enforcer(cls) -> SyncedEnforcer:
158200
"""Get the enforcer instance, creating it if needed.
@@ -163,6 +205,9 @@ def get_enforcer(cls) -> SyncedEnforcer:
163205
if cls._enforcer is None:
164206
cls._enforcer = cls._initialize_enforcer()
165207

208+
# (re)load policy if needed
209+
cls.load_policy_if_needed()
210+
166211
# HACK: This code block will only be useful when in Ulmo to deactivate
167212
# the enforcer when the new library experience is disabled. It should be
168213
# removed for the next release cycle.
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
# Generated by Django 4.2.24 on 2025-11-14 22:38
2+
3+
import uuid
4+
5+
from django.db import migrations, models
6+
7+
8+
class Migration(migrations.Migration):
9+
dependencies = [
10+
("openedx_authz", "0004_contentlibraryscope"),
11+
]
12+
13+
operations = [
14+
migrations.CreateModel(
15+
name="PolicyCacheControl",
16+
fields=[
17+
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
18+
("version", models.UUIDField(default=uuid.uuid4)),
19+
],
20+
),
21+
]

openedx_authz/migrations/0005_migrate_legacy_permissions.py renamed to openedx_authz/migrations/0006_migrate_legacy_permissions.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ class Migration(migrations.Migration):
5353
"""
5454

5555
dependencies = [
56-
("openedx_authz", "0004_contentlibraryscope"),
56+
("openedx_authz", "0005_policycachecontrol"),
5757
]
5858

5959
operations = [

openedx_authz/models/engine.py

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
"""Models for the authorization engine."""
2+
3+
from uuid import UUID, uuid4
4+
5+
from django.db import models
6+
7+
8+
class PolicyCacheControl(models.Model):
9+
"""Model to control policy cache invalidation.
10+
11+
This model can be used to trigger cache invalidation for authorization policies
12+
by changing the version. Whenever this model is updated, the authorization
13+
engine should invalidate its cached policies.
14+
15+
.. no_pii:
16+
"""
17+
18+
version = models.UUIDField(default=uuid4)
19+
20+
def save(self, *args, **kwargs):
21+
"""Override save to ensure a single instance."""
22+
self.pk = 1 # Ensure a single instance
23+
super().save(*args, **kwargs)
24+
25+
@classmethod
26+
def get(cls):
27+
"""Get the singleton instance of the model."""
28+
obj, _ = cls.objects.get_or_create(pk=1)
29+
return obj
30+
31+
@classmethod
32+
def get_version(cls):
33+
"""Get the version for policy cache control.
34+
35+
Returns:
36+
UUID: The version of the last update.
37+
"""
38+
instance = cls.get()
39+
return instance.version
40+
41+
@classmethod
42+
def set_version(cls, version: UUID):
43+
"""Update the cache version.
44+
45+
This method updates the cache version, which can be used to signal
46+
that the policy cache should be invalidated.
47+
"""
48+
instance = cls.get()
49+
instance.version = version
50+
51+
instance.save()

openedx_authz/rest_api/v1/permissions.py

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@
55
from rest_framework.permissions import BasePermission
66

77
from openedx_authz import api
8-
from openedx_authz.engine.enforcer import AuthzEnforcer
98

109

1110
class PermissionMeta(type(BasePermission)):
@@ -183,7 +182,6 @@ def has_permission(self, request, view) -> bool:
183182
"""
184183
if request.user.is_superuser or request.user.is_staff:
185184
return True
186-
AuthzEnforcer.get_enforcer().load_policy()
187185
return self._get_permission_instance(request).has_permission(request, view)
188186

189187
def has_object_permission(self, request, view, obj) -> bool:
@@ -200,7 +198,6 @@ def has_object_permission(self, request, view, obj) -> bool:
200198
"""
201199
if request.user.is_superuser or request.user.is_staff:
202200
return True
203-
AuthzEnforcer.get_enforcer().load_policy()
204201
return self._get_permission_instance(request).has_object_permission(request, view, obj)
205202

206203

openedx_authz/rest_api/v1/views.py

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,6 @@
1616

1717
from openedx_authz import api
1818
from openedx_authz.constants import permissions
19-
from openedx_authz.engine.enforcer import AuthzEnforcer
2019
from openedx_authz.rest_api.data import RoleOperationError, RoleOperationStatus
2120
from openedx_authz.rest_api.decorators import authz_permissions, view_auth_classes
2221
from openedx_authz.rest_api.utils import (
@@ -103,7 +102,6 @@ class PermissionValidationMeView(APIView):
103102
)
104103
def post(self, request: HttpRequest) -> Response:
105104
"""Validate one or more permissions for the authenticated user."""
106-
AuthzEnforcer.get_enforcer().load_policy()
107105

108106
serializer = PermissionValidationSerializer(data=request.data, many=True)
109107
serializer.is_valid(raise_exception=True)

openedx_authz/settings/common.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,8 +31,10 @@ def plugin_settings(settings):
3131
# Set default CASBIN_AUTO_LOAD_POLICY_INTERVAL if not already set.
3232
# This setting defines how often (in seconds) the Casbin enforcer should
3333
# automatically reload policies from the database.
34+
# By default, we set it to 0, which disables the auto-reload.
35+
# As it shouldn't be needed thanks to cache invalidation.
3436
if not hasattr(settings, "CASBIN_AUTO_LOAD_POLICY_INTERVAL"):
35-
settings.CASBIN_AUTO_LOAD_POLICY_INTERVAL = 5
37+
settings.CASBIN_AUTO_LOAD_POLICY_INTERVAL = 0
3638

3739
# Set default CASBIN_AUTO_SAVE_POLICY if not already set.
3840
# This setting defines whether the Casbin enforcer should automatically

0 commit comments

Comments
 (0)