Skip to content

Commit a3b5cdd

Browse files
[FC-0099] refactor: manage enforcer state and casbin models only when app is ready (#93)
1 parent e7176ef commit a3b5cdd

File tree

11 files changed

+176
-40
lines changed

11 files changed

+176
-40
lines changed

CHANGELOG.rst

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,13 +16,21 @@ Unreleased
1616

1717
*
1818

19-
0.1.0 - 2025-08-27
19+
0.4.0 - 2025-16-10
20+
******************
21+
22+
Changed
23+
=======
24+
25+
* Initialize enforcer when application is ready to avoid access errors.
26+
27+
0.3.0 - 2025-10-10
2028
******************
2129

2230
Added
2331
=====
2432

25-
* Basic repo structure and initial setup.
33+
* Implementation of REST API for roles and permissions management.
2634

2735
0.2.0 - 2025-10-10
2836
******************
@@ -34,10 +42,10 @@ Added
3442
* Casbin model (CONF) and engine layer for authorization.
3543
* Implementation of public API for roles and permissions management.
3644

37-
0.3.0 - 2025-10-10
45+
0.1.0 - 2025-08-27
3846
******************
3947

4048
Added
4149
=====
4250

43-
* Implementation of REST API for roles and permissions management.
51+
* Basic repo structure and initial setup.

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.3.0"
7+
__version__ = "0.4.0"
88

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

openedx_authz/api/permissions.py

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
"""
77

88
from openedx_authz.api.data import ActionData, PermissionData, PolicyIndex, ScopeData, SubjectData
9-
from openedx_authz.engine.enforcer import enforcer
9+
from openedx_authz.engine.enforcer import AuthzEnforcer
1010

1111
__all__ = [
1212
"get_permission_from_policy",
@@ -42,7 +42,10 @@ def get_all_permissions_in_scope(scope: ScopeData) -> list[PermissionData]:
4242
Returns:
4343
list of PermissionData: A list of PermissionData objects associated with the given scope.
4444
"""
45-
actions = enforcer.get_filtered_policy(PolicyIndex.SCOPE.value, scope.namespaced_key)
45+
enforcer = AuthzEnforcer.get_enforcer()
46+
actions = enforcer.get_filtered_policy(
47+
PolicyIndex.SCOPE.value, scope.namespaced_key
48+
)
4649
return [get_permission_from_policy(action) for action in actions]
4750

4851

@@ -61,5 +64,8 @@ def is_subject_allowed(
6164
Returns:
6265
bool: True if the subject has the specified permission in the scope, False otherwise.
6366
"""
67+
enforcer = AuthzEnforcer.get_enforcer()
6468
enforcer.load_policy()
65-
return enforcer.enforce(subject.namespaced_key, action.namespaced_key, scope.namespaced_key)
69+
return enforcer.enforce(
70+
subject.namespaced_key, action.namespaced_key, scope.namespaced_key
71+
)

openedx_authz/api/roles.py

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
SubjectData,
2121
)
2222
from openedx_authz.api.permissions import get_permission_from_policy
23-
from openedx_authz.engine.enforcer import enforcer
23+
from openedx_authz.engine.enforcer import AuthzEnforcer
2424

2525
__all__ = [
2626
"get_permissions_for_single_role",
@@ -59,6 +59,7 @@ def get_permissions_for_single_role(
5959
Returns:
6060
list[PermissionData]: A list of PermissionData objects associated with the given role.
6161
"""
62+
enforcer = AuthzEnforcer.get_enforcer()
6263
policies = enforcer.get_implicit_permissions_for_user(role.namespaced_key)
6364
return [get_permission_from_policy(policy) for policy in policies]
6465

@@ -114,6 +115,7 @@ def get_permissions_for_active_roles_in_scope(
114115
dict[str, list[PermissionData]]: A dictionary mapping the role external_key to its
115116
permissions and scopes.
116117
"""
118+
enforcer = AuthzEnforcer.get_enforcer()
117119
enforcer.load_policy()
118120
filtered_policy = enforcer.get_filtered_grouping_policy(
119121
GroupingPolicyIndex.SCOPE.value, scope.namespaced_key
@@ -146,6 +148,7 @@ def get_role_definitions_in_scope(scope: ScopeData) -> list[RoleData]:
146148
Returns:
147149
list[Role]: A list of roles.
148150
"""
151+
enforcer = AuthzEnforcer.get_enforcer()
149152
enforcer.load_policy()
150153
policy_filtered = enforcer.get_filtered_policy(
151154
PolicyIndex.SCOPE.value, scope.namespaced_key
@@ -180,7 +183,7 @@ def get_all_roles_names() -> list[str]:
180183
Returns:
181184
list[str]: A list of role names.
182185
"""
183-
return enforcer.get_all_subjects()
186+
return AuthzEnforcer.get_enforcer().get_all_subjects()
184187

185188

186189
def get_all_roles_in_scope(scope: ScopeData) -> list[list[str]]:
@@ -192,6 +195,7 @@ def get_all_roles_in_scope(scope: ScopeData) -> list[list[str]]:
192195
Returns:
193196
list[list[str]]: A list of policies in the specified scope.
194197
"""
198+
enforcer = AuthzEnforcer.get_enforcer()
195199
enforcer.load_policy()
196200
return enforcer.get_filtered_grouping_policy(
197201
GroupingPolicyIndex.SCOPE.value, scope.namespaced_key
@@ -211,6 +215,7 @@ def assign_role_to_subject_in_scope(
211215
Returns:
212216
bool: True if the role was assigned successfully, False otherwise.
213217
"""
218+
enforcer = AuthzEnforcer.get_enforcer()
214219
enforcer.load_policy()
215220
return enforcer.add_role_for_user_in_domain(
216221
subject.namespaced_key,
@@ -245,6 +250,7 @@ def unassign_role_from_subject_in_scope(
245250
Returns:
246251
bool: True if the role was unassigned successfully, False otherwise.
247252
"""
253+
enforcer = AuthzEnforcer.get_enforcer()
248254
enforcer.load_policy()
249255
return enforcer.delete_roles_for_user_in_domain(
250256
subject.namespaced_key, role.namespaced_key, scope.namespaced_key
@@ -274,6 +280,7 @@ def get_subject_role_assignments(subject: SubjectData) -> list[RoleAssignmentDat
274280
Returns:
275281
list[RoleAssignmentData]: A list of role assignments for the subject.
276282
"""
283+
enforcer = AuthzEnforcer.get_enforcer()
277284
role_assignments = []
278285
for policy in enforcer.get_filtered_grouping_policy(
279286
GroupingPolicyIndex.SUBJECT.value, subject.namespaced_key
@@ -303,6 +310,7 @@ def get_subject_role_assignments_in_scope(
303310
Returns:
304311
list[RoleAssignmentData]: A list of role assignments for the subject in the scope.
305312
"""
313+
enforcer = AuthzEnforcer.get_enforcer()
306314
enforcer.load_policy()
307315
# TODO: we still need to get the remaining data for the role like email, etc
308316
role_assignments = []
@@ -337,6 +345,7 @@ def get_subject_role_assignments_for_role_in_scope(
337345
Returns:
338346
list[RoleAssignmentData]: A list of subjects assigned to the specified role in the specified scope.
339347
"""
348+
enforcer = AuthzEnforcer.get_enforcer()
340349
role_assignments = []
341350
for subject in enforcer.get_users_for_role_in_domain(
342351
role.namespaced_key, scope.namespaced_key
@@ -402,6 +411,7 @@ def get_subjects_for_role(role: RoleData) -> list[SubjectData]:
402411
Returns:
403412
list[SubjectData]: A list of subjects assigned to the specified role.
404413
"""
414+
enforcer = AuthzEnforcer.get_enforcer()
405415
enforcer.load_policy()
406416
policies = enforcer.get_filtered_grouping_policy(GroupingPolicyIndex.ROLE.value, role.namespaced_key)
407417
return [SubjectData(namespaced_key=policy[GroupingPolicyIndex.SUBJECT.value]) for policy in policies]

openedx_authz/engine/apps.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
"""Initialization for the casbin_adapter Django application.
2+
3+
This overrides the default AppConfig to avoid making queries to the database
4+
when the app is not fully loaded (e.g., while pulling translations). Moved
5+
the initialization of the enforcer to a lazy load when it's first used.
6+
7+
See openedx_authz/engine/enforcer.py for the enforcer implementation.
8+
"""
9+
10+
from django.apps import AppConfig
11+
12+
13+
class CasbinAdapterConfig(AppConfig):
14+
name = "casbin_adapter"
15+
16+
def ready(self):
17+
"""Initialize the casbin_adapter app.
18+
19+
The upstream casbin_adapter app tries to initialize the enforcer
20+
when the app is loaded, which can lead to issues if the database is not
21+
ready (e.g., while pulling translations). To avoid this, we override
22+
the ready method and do not initialize the enforcer here.
23+
"""

openedx_authz/engine/enforcer.py

Lines changed: 83 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
- Watcher: Redis-based watcher for real-time policy updates
1111
1212
Usage:
13-
from openedx_authz.engine.enforcer import enforcer
13+
from openedx_authz.engine.enforcer import AuthzEnforcer
1414
allowed = enforcer.enforce(user, resource, action)
1515
1616
Requires `CASBIN_MODEL` setting and Redis configuration for watcher functionality.
@@ -19,20 +19,93 @@
1919
import logging
2020

2121
from casbin import FastEnforcer
22+
from casbin_adapter.enforcer import initialize_enforcer
2223
from django.conf import settings
2324

2425
from openedx_authz.engine.adapter import ExtendedAdapter
2526
from openedx_authz.engine.watcher import Watcher
2627

2728
logger = logging.getLogger(__name__)
2829

29-
adapter = ExtendedAdapter()
30-
enforcer = FastEnforcer(settings.CASBIN_MODEL, adapter, enable_log=True)
31-
enforcer.enable_auto_save(True)
3230

33-
if Watcher:
34-
try:
35-
enforcer.set_watcher(Watcher)
36-
logger.info("Watcher successfully set on Casbin enforcer")
37-
except Exception as e: # pylint: disable=broad-exception-caught
38-
logger.error(f"Failed to set watcher on Casbin enforcer: {e}")
31+
class AuthzEnforcer:
32+
"""Singleton class to manage the Casbin FastEnforcer instance.
33+
34+
Ensures a single enforcer instance is created safely and configured with the
35+
ExtendedAdapter and Redis watcher for policy management and synchronization.
36+
37+
There are two main use cases for this class:
38+
39+
1. Directly get the enforcer instance and initialize it if needed::
40+
41+
from openedx_authz.engine.enforcer import AuthzEnforcer
42+
enforcer = AuthzEnforcer.get_enforcer()
43+
allowed = enforcer.enforce(user, resource, action)
44+
45+
2. Instantiate the class to get the singleton enforcer instance::
46+
47+
from openedx_authz.engine.enforcer import AuthzEnforcer
48+
enforcer = AuthzEnforcer()
49+
allowed = enforcer.get_enforcer().enforce(user, resource, action)
50+
51+
Any of the two approaches will yield the same singleton enforcer instance.
52+
"""
53+
54+
_enforcer = None
55+
56+
def __new__(cls):
57+
"""Singleton pattern to ensure a single enforcer instance."""
58+
if cls._enforcer is None:
59+
cls._enforcer = cls._initialize_enforcer()
60+
return cls._enforcer
61+
62+
@classmethod
63+
def get_enforcer(cls) -> FastEnforcer:
64+
"""Get the enforcer instance, creating it if needed.
65+
66+
Returns:
67+
FastEnforcer: The singleton enforcer instance.
68+
"""
69+
if cls._enforcer is None:
70+
cls._enforcer = cls._initialize_enforcer()
71+
return cls._enforcer
72+
73+
@staticmethod
74+
def _initialize_enforcer() -> FastEnforcer:
75+
"""
76+
Create and configure the Casbin FastEnforcer instance.
77+
78+
This method initializes the FastEnforcer with the ExtendedAdapter
79+
for database policy storage and sets up the Redis watcher for real-time
80+
policy synchronization if the Watcher is available. It also initializes
81+
the enforcer with the specified database alias from settings.
82+
83+
Returns:
84+
FastEnforcer: Configured Casbin enforcer with adapter and watcher
85+
"""
86+
db_alias = getattr(settings, "CASBIN_DB_ALIAS", "default")
87+
88+
try:
89+
# Initialize the enforcer with the specified database alias to set up the adapter.
90+
# Best to lazy load it when it's first used to ensure the database is ready and avoid
91+
# issues when the app is not fully loaded (e.g., while pulling translations, etc.).
92+
initialize_enforcer(db_alias)
93+
except Exception as e:
94+
logger.error(f"Failed to initialize Casbin enforcer with DB alias '{db_alias}': {e}")
95+
raise
96+
97+
adapter = ExtendedAdapter()
98+
enforcer = FastEnforcer(settings.CASBIN_MODEL, adapter, enable_log=True)
99+
enforcer.enable_auto_save(True)
100+
101+
if not Watcher:
102+
logger.warning("Redis configuration not completed successfully. Watcher is disabled.")
103+
return enforcer
104+
105+
try:
106+
enforcer.set_watcher(Watcher)
107+
logger.info("Watcher successfully set on Casbin enforcer")
108+
except Exception as e: # pylint: disable=broad-exception-caught
109+
logger.error(f"Failed to set watcher on Casbin enforcer: {e}")
110+
111+
return enforcer

openedx_authz/management/commands/load_policies.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
from django.core.management.base import BaseCommand
1313

1414
from openedx_authz import ROOT_DIRECTORY
15-
from openedx_authz.engine.enforcer import enforcer as global_enforcer
15+
from openedx_authz.engine.enforcer import AuthzEnforcer
1616
from openedx_authz.engine.utils import migrate_policy_between_enforcers
1717

1818

@@ -74,7 +74,7 @@ def handle(self, *args, **options):
7474
)
7575

7676
source_enforcer = casbin.Enforcer(model_file_path, policy_file_path)
77-
self.migrate_policies(source_enforcer, global_enforcer)
77+
self.migrate_policies(source_enforcer, AuthzEnforcer.get_enforcer())
7878

7979
def migrate_policies(self, source_enforcer, target_enforcer):
8080
"""Migrate policies from the source enforcer to the target enforcer.

openedx_authz/settings/common.py

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,13 +17,14 @@ def plugin_settings(settings):
1717
settings: The Django settings object
1818
"""
1919
# Add external third-party apps to INSTALLED_APPS
20-
casbin_adapter_app = "casbin_adapter.apps.CasbinAdapterConfig"
20+
casbin_adapter_app = "openedx_authz.engine.apps.CasbinAdapterConfig"
2121
if casbin_adapter_app not in settings.INSTALLED_APPS:
2222
settings.INSTALLED_APPS.append(casbin_adapter_app)
23-
2423
# Add Casbin configuration
25-
settings.CASBIN_MODEL = os.path.join(ROOT_DIRECTORY, "engine", "config", "model.conf")
26-
settings.CASBIN_WATCHER_ENABLED = True
24+
settings.CASBIN_MODEL = os.path.join(
25+
ROOT_DIRECTORY, "engine", "config", "model.conf"
26+
)
27+
settings.CASBIN_WATCHER_ENABLED = False
2728
# TODO: Replace with a more dynamic configuration
2829
# Redis host and port are temporarily loaded here for the MVP
2930
settings.REDIS_HOST = "redis"

openedx_authz/settings/test.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,8 @@
2828
"django.contrib.contenttypes",
2929
"django.contrib.messages",
3030
"django.contrib.sessions",
31+
"openedx_authz.engine.apps.CasbinAdapterConfig",
3132
"openedx_authz.apps.OpenedxAuthzConfig",
32-
"casbin_adapter.apps.CasbinAdapterConfig",
3333
)
3434

3535
MIDDLEWARE = [

0 commit comments

Comments
 (0)