Skip to content

Commit 1a2207a

Browse files
authored
[FC-0099] feat: use synced enforcer (openedx#103)
1 parent c911424 commit 1a2207a

9 files changed

Lines changed: 145 additions & 56 deletions

File tree

CHANGELOG.rst

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,8 @@ Change Log
1414
Unreleased
1515
**********
1616

17-
*
17+
* Use a SyncedEnforcer with default auto load policy.
18+
1819

1920
0.5.0 - 2025-10-21
2021
******************

openedx_authz/api/permissions.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,6 @@ def is_subject_allowed(
6565
bool: True if the subject has the specified permission in the scope, False otherwise.
6666
"""
6767
enforcer = AuthzEnforcer.get_enforcer()
68-
enforcer.load_policy()
6968
return enforcer.enforce(
7069
subject.namespaced_key, action.namespaced_key, scope.namespaced_key
7170
)

openedx_authz/api/roles.py

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,6 @@ def get_permissions_for_active_roles_in_scope(
116116
permissions and scopes.
117117
"""
118118
enforcer = AuthzEnforcer.get_enforcer()
119-
enforcer.load_policy()
120119
filtered_policy = enforcer.get_filtered_grouping_policy(
121120
GroupingPolicyIndex.SCOPE.value, scope.namespaced_key
122121
)
@@ -149,7 +148,6 @@ def get_role_definitions_in_scope(scope: ScopeData) -> list[RoleData]:
149148
list[Role]: A list of roles.
150149
"""
151150
enforcer = AuthzEnforcer.get_enforcer()
152-
enforcer.load_policy()
153151
policy_filtered = enforcer.get_filtered_policy(
154152
PolicyIndex.SCOPE.value, scope.namespaced_key
155153
)
@@ -196,7 +194,6 @@ def get_all_roles_in_scope(scope: ScopeData) -> list[list[str]]:
196194
list[list[str]]: A list of policies in the specified scope.
197195
"""
198196
enforcer = AuthzEnforcer.get_enforcer()
199-
enforcer.load_policy()
200197
return enforcer.get_filtered_grouping_policy(
201198
GroupingPolicyIndex.SCOPE.value, scope.namespaced_key
202199
)
@@ -216,7 +213,6 @@ def assign_role_to_subject_in_scope(
216213
bool: True if the role was assigned successfully, False otherwise.
217214
"""
218215
enforcer = AuthzEnforcer.get_enforcer()
219-
enforcer.load_policy()
220216
return enforcer.add_role_for_user_in_domain(
221217
subject.namespaced_key,
222218
role.namespaced_key,
@@ -251,7 +247,6 @@ def unassign_role_from_subject_in_scope(
251247
bool: True if the role was unassigned successfully, False otherwise.
252248
"""
253249
enforcer = AuthzEnforcer.get_enforcer()
254-
enforcer.load_policy()
255250
return enforcer.delete_roles_for_user_in_domain(
256251
subject.namespaced_key, role.namespaced_key, scope.namespaced_key
257252
)
@@ -311,7 +306,6 @@ def get_subject_role_assignments_in_scope(
311306
list[RoleAssignmentData]: A list of role assignments for the subject in the scope.
312307
"""
313308
enforcer = AuthzEnforcer.get_enforcer()
314-
enforcer.load_policy()
315309
# TODO: we still need to get the remaining data for the role like email, etc
316310
role_assignments = []
317311
for namespaced_key in enforcer.get_roles_for_user_in_domain(
@@ -412,6 +406,5 @@ def get_subjects_for_role(role: RoleData) -> list[SubjectData]:
412406
list[SubjectData]: A list of subjects assigned to the specified role.
413407
"""
414408
enforcer = AuthzEnforcer.get_enforcer()
415-
enforcer.load_policy()
416409
policies = enforcer.get_filtered_grouping_policy(GroupingPolicyIndex.ROLE.value, role.namespaced_key)
417410
return [SubjectData(namespaced_key=policy[GroupingPolicyIndex.SUBJECT.value]) for policy in policies]

openedx_authz/engine/enforcer.py

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818

1919
import logging
2020

21-
from casbin import FastEnforcer
21+
from casbin import SyncedEnforcer
2222
from casbin_adapter.enforcer import initialize_enforcer
2323
from django.conf import settings
2424

@@ -29,7 +29,7 @@
2929

3030

3131
class AuthzEnforcer:
32-
"""Singleton class to manage the Casbin FastEnforcer instance.
32+
"""Singleton class to manage the Casbin SyncedEnforcer instance.
3333
3434
Ensures a single enforcer instance is created safely and configured with the
3535
ExtendedAdapter and Redis watcher for policy management and synchronization.
@@ -60,28 +60,28 @@ def __new__(cls):
6060
return cls._enforcer
6161

6262
@classmethod
63-
def get_enforcer(cls) -> FastEnforcer:
63+
def get_enforcer(cls) -> SyncedEnforcer:
6464
"""Get the enforcer instance, creating it if needed.
6565
6666
Returns:
67-
FastEnforcer: The singleton enforcer instance.
67+
SyncedEnforcer: The singleton enforcer instance.
6868
"""
6969
if cls._enforcer is None:
7070
cls._enforcer = cls._initialize_enforcer()
7171
return cls._enforcer
7272

7373
@staticmethod
74-
def _initialize_enforcer() -> FastEnforcer:
74+
def _initialize_enforcer() -> SyncedEnforcer:
7575
"""
76-
Create and configure the Casbin FastEnforcer instance.
76+
Create and configure the Casbin SyncedEnforcer instance.
7777
7878
This method initializes the FastEnforcer with the ExtendedAdapter
7979
for database policy storage and sets up the Redis watcher for real-time
8080
policy synchronization if the Watcher is available. It also initializes
8181
the enforcer with the specified database alias from settings.
8282
8383
Returns:
84-
FastEnforcer: Configured Casbin enforcer with adapter and watcher
84+
SyncedEnforcer: Configured Casbin enforcer with adapter and watcher
8585
"""
8686
db_alias = getattr(settings, "CASBIN_DB_ALIAS", "default")
8787

@@ -95,7 +95,8 @@ def _initialize_enforcer() -> FastEnforcer:
9595
raise
9696

9797
adapter = ExtendedAdapter()
98-
enforcer = FastEnforcer(settings.CASBIN_MODEL, adapter, enable_log=True)
98+
enforcer = SyncedEnforcer(settings.CASBIN_MODEL, adapter)
99+
enforcer.start_auto_load_policy(settings.CASBIN_AUTO_LOAD_POLICY_INTERVAL)
99100
enforcer.enable_auto_save(True)
100101

101102
if not Watcher:

openedx_authz/rest_api/v1/views.py

Lines changed: 8 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -76,18 +76,14 @@ class PermissionValidationMeView(APIView):
7676
7777
**Example Request**
7878
79-
POST /api/authz/v1/permissions/validate/me
80-
81-
.. code-block:: json
79+
POST /api/authz/v1/permissions/validate/me::
8280
8381
[
8482
{"action": "edit_library", "scope": "lib:DemoX:CSPROB"},
8583
{"action": "delete_library_content", "scope": "lib:OpenedX:CS50"}
8684
]
8785
88-
**Example Response**
89-
90-
.. code-block:: json
86+
**Example Response**::
9187
9288
[
9389
{"action": "edit_library", "scope": "lib:DemoX:CSPROB", "allowed": true},
@@ -115,13 +111,7 @@ def post(self, request: HttpRequest) -> Response:
115111
action = perm["action"]
116112
scope = perm["scope"]
117113
allowed = api.is_user_allowed(username, action, scope)
118-
response_data.append(
119-
{
120-
"action": action,
121-
"scope": scope,
122-
"allowed": allowed,
123-
}
124-
)
114+
response_data.append({"action": action, "scope": scope, "allowed": allowed})
125115
except ValueError as e:
126116
logger.error(f"Error validating permission for user {username}: {e}")
127117
return Response(data={"message": "Invalid scope format"}, status=status.HTTP_400_BAD_REQUEST)
@@ -178,9 +168,7 @@ class RoleUserAPIView(APIView):
178168
179169
**Response Format (GET)**
180170
181-
Returns HTTP 200 OK with:
182-
183-
.. code-block:: json
171+
Returns HTTP 200 OK with::
184172
185173
{
186174
"count": 2,
@@ -204,9 +192,7 @@ class RoleUserAPIView(APIView):
204192
205193
**Response Format (PUT)**
206194
207-
Returns HTTP 207 Multi-Status with:
208-
209-
.. code-block:: json
195+
Returns HTTP 207 Multi-Status with::
210196
211197
{
212198
"completed": [{"user_identifier": "john_doe", "status": "role_added"}],
@@ -215,9 +201,7 @@ class RoleUserAPIView(APIView):
215201
216202
**Response Format (DELETE)**
217203
218-
Returns HTTP 207 Multi-Status with:
219-
220-
.. code-block:: json
204+
Returns HTTP 207 Multi-Status with::
221205
222206
{
223207
"completed": [{"user_identifier": "john_doe", "status": "role_removed"}],
@@ -233,9 +217,7 @@ class RoleUserAPIView(APIView):
233217
234218
GET /api/authz/v1/roles/users/?scope=lib:DemoX:CSPROB&search=john&roles=library_admin
235219
236-
PUT /api/authz/v1/roles/users/
237-
238-
.. code-block:: json
220+
PUT /api/authz/v1/roles/users/ ::
239221
240222
{
241223
"role": "library_admin",
@@ -404,9 +386,7 @@ class RoleListView(APIView):
404386
405387
GET /api/authz/v1/roles/?scope=lib:OpenedX:CSPROB&page=1&page_size=10
406388
407-
**Example Response**
408-
409-
.. code-block:: json
389+
**Example Response**::
410390
411391
{
412392
"count": 2,

openedx_authz/settings/common.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ def plugin_settings(settings):
2525
ROOT_DIRECTORY, "engine", "config", "model.conf"
2626
)
2727
settings.CASBIN_WATCHER_ENABLED = False
28+
if not hasattr(settings, "CASBIN_AUTO_LOAD_POLICY_INTERVAL"):
29+
settings.CASBIN_AUTO_LOAD_POLICY_INTERVAL = 5
2830
# TODO: Replace with a more dynamic configuration
2931
# Redis host and port are temporarily loaded here for the MVP
3032
settings.REDIS_HOST = "redis"

openedx_authz/settings/test.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ def plugin_settings(settings): # pylint: disable=unused-argument
6969

7070
# Casbin configuration
7171
CASBIN_MODEL = os.path.join(ROOT_DIRECTORY, "engine", "config", "model.conf")
72+
CASBIN_AUTO_LOAD_POLICY_INTERVAL = 1
7273
CASBIN_WATCHER_ENABLED = False
7374
REDIS_HOST = "redis"
7475
REDIS_PORT = 6379

openedx_authz/tests/api/test_roles.py

Lines changed: 22 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,28 @@ def setUpClass(cls):
9595
super().setUpClass()
9696
cls._seed_database_with_policies()
9797

98+
@classmethod
99+
def tearDownClass(cls):
100+
"""Clean up after all tests in the class.
101+
102+
Stops the auto-load policy thread to prevent database locking issues
103+
with SQLite during concurrent access.
104+
"""
105+
super().tearDownClass()
106+
enforcer = AuthzEnforcer.get_enforcer()
107+
if hasattr(enforcer, 'stop_auto_load_policy'):
108+
enforcer.stop_auto_load_policy()
109+
110+
def setUp(self):
111+
"""Set up test environment."""
112+
super().setUp()
113+
AuthzEnforcer.get_enforcer().load_policy() # Load policies before each test to simulate fresh start
114+
115+
def tearDown(self):
116+
"""Clean up after each test to ensure isolation."""
117+
super().tearDown()
118+
AuthzEnforcer.get_enforcer().clear_policy() # Clear policies after each test to ensure isolation
119+
98120

99121
class RolesTestSetupMixin(BaseRolesTestCase):
100122
"""Test case with comprehensive role assignments for general roles testing."""
@@ -230,16 +252,6 @@ def setUpClass(cls):
230252
]
231253
cls._assign_roles_to_users(assignments=assignments)
232254

233-
def setUp(self):
234-
"""Set up test environment."""
235-
super().setUp()
236-
AuthzEnforcer.get_enforcer().load_policy() # Load policies before each test to simulate fresh start
237-
238-
def tearDown(self):
239-
"""Clean up after each test to ensure isolation."""
240-
super().tearDown()
241-
AuthzEnforcer.get_enforcer().clear_policy() # Clear policies after each test to ensure isolation
242-
243255

244256
@ddt
245257
class TestRolesAPI(RolesTestSetupMixin):

0 commit comments

Comments
 (0)