From ed9ef8f216eda7fe7c88c521c76f89d8d6ef64c4 Mon Sep 17 00:00:00 2001 From: Maria Grimaldi Date: Fri, 10 Oct 2025 15:30:19 +0200 Subject: [PATCH 01/21] refactor: manage enforcer state within class when app is ready --- openedx_authz/apps.py | 6 ++++ openedx_authz/engine/enforcer.py | 48 ++++++++++++++++++++++++++------ 2 files changed, 45 insertions(+), 9 deletions(-) diff --git a/openedx_authz/apps.py b/openedx_authz/apps.py index 7de0d16b..ea041df1 100644 --- a/openedx_authz/apps.py +++ b/openedx_authz/apps.py @@ -39,3 +39,9 @@ class OpenedxAuthzConfig(AppConfig): }, }, } + + def ready(self): + """Initialization layer for the openedx_authz app.""" + from openedx_authz.engine.enforcer import AuthzEnforcer + # Initialize the enforcer to ensure it's ready when the app starts + AuthzEnforcer() diff --git a/openedx_authz/engine/enforcer.py b/openedx_authz/engine/enforcer.py index f9c8a335..c1ad8aa2 100644 --- a/openedx_authz/engine/enforcer.py +++ b/openedx_authz/engine/enforcer.py @@ -21,18 +21,48 @@ from casbin import FastEnforcer from django.conf import settings +from openedx_authz.engine import adapter from openedx_authz.engine.adapter import ExtendedAdapter from openedx_authz.engine.watcher import Watcher logger = logging.getLogger(__name__) -adapter = ExtendedAdapter() -enforcer = FastEnforcer(settings.CASBIN_MODEL, adapter, enable_log=True) -enforcer.enable_auto_save(True) -if Watcher: - try: - enforcer.set_watcher(Watcher) - logger.info("Watcher successfully set on Casbin enforcer") - except Exception as e: # pylint: disable=broad-exception-caught - logger.error(f"Failed to set watcher on Casbin enforcer: {e}") +class AuthzEnforcer: + """Singleton class to manage the Casbin FastEnforcer instance. + + Ensures a single enforcer instance is created safely and configured with the + ExtendedAdapter and Redis watcher for policy management and synchronization. + """ + + enforcer = None + + def __new__(cls): + """Singleton pattern to ensure a single enforcer instance.""" + if cls.enforcer is None: + cls.enforcer = cls.initialize_enforcer() + return cls.enforcer + + def initialize_enforcer(self) -> FastEnforcer: + """ + Create and configure the Casbin FastEnforcer instance. + + This function initializes the Casbin FastEnforcer with the ExtendedAdapter + for database-backed policy storage and sets up the Redis watcher for + real-time policy synchronization. + + Returns: + FastEnforcer: Configured Casbin enforcer with adapter and watcher + """ + adapter = ExtendedAdapter() + enforcer = FastEnforcer(settings.CASBIN_MODEL, adapter, enable_log=True) + enforcer.enable_auto_save(True) + + if Watcher: + try: + enforcer.set_watcher(Watcher) + logger.info("Watcher successfully set on Casbin enforcer") + except Exception as e: # pylint: disable=broad-exception-caught + logger.error(f"Failed to set watcher on Casbin enforcer: {e}") + + return enforcer From 19bf8df597bd925574d2d53d23542fcd11d42560 Mon Sep 17 00:00:00 2001 From: Maria Grimaldi Date: Fri, 10 Oct 2025 16:01:46 +0200 Subject: [PATCH 02/21] refactor: get class attribute in runtime so it's available --- openedx_authz/api/permissions.py | 4 ++- openedx_authz/api/roles.py | 4 ++- openedx_authz/engine/enforcer.py | 28 +++++++++++++++---- .../management/commands/load_policies.py | 4 ++- openedx_authz/tests/api/test_roles.py | 4 ++- openedx_authz/tests/test_enforcer.py | 4 ++- 6 files changed, 37 insertions(+), 11 deletions(-) diff --git a/openedx_authz/api/permissions.py b/openedx_authz/api/permissions.py index 097ebb81..346bc9dc 100644 --- a/openedx_authz/api/permissions.py +++ b/openedx_authz/api/permissions.py @@ -6,7 +6,9 @@ """ from openedx_authz.api.data import ActionData, PermissionData, PolicyIndex, ScopeData, SubjectData -from openedx_authz.engine.enforcer import enforcer +from openedx_authz.engine.enforcer import AuthzEnforcer + +enforcer = AuthzEnforcer.get_enforcer() __all__ = [ "get_permission_from_policy", diff --git a/openedx_authz/api/roles.py b/openedx_authz/api/roles.py index c1db6c9f..58794bd6 100644 --- a/openedx_authz/api/roles.py +++ b/openedx_authz/api/roles.py @@ -20,7 +20,9 @@ SubjectData, ) from openedx_authz.api.permissions import get_permission_from_policy -from openedx_authz.engine.enforcer import enforcer +from openedx_authz.engine.enforcer import AuthzEnforcer + +enforcer = AuthzEnforcer.get_enforcer() __all__ = [ "get_permissions_for_single_role", diff --git a/openedx_authz/engine/enforcer.py b/openedx_authz/engine/enforcer.py index c1ad8aa2..149aaa35 100644 --- a/openedx_authz/engine/enforcer.py +++ b/openedx_authz/engine/enforcer.py @@ -10,7 +10,7 @@ - Watcher: Redis-based watcher for real-time policy updates Usage: - from openedx_authz.engine.enforcer import enforcer + from openedx_authz.engine.enforcer import AuthzEnforcer allowed = enforcer.enforce(user, resource, action) Requires `CASBIN_MODEL` setting and Redis configuration for watcher functionality. @@ -33,17 +33,33 @@ class AuthzEnforcer: Ensures a single enforcer instance is created safely and configured with the ExtendedAdapter and Redis watcher for policy management and synchronization. + + Usage: + enforcer = AuthzEnforcer.get_enforcer() + allowed = enforcer.enforce(user, resource, action) """ - enforcer = None + _enforcer = None def __new__(cls): """Singleton pattern to ensure a single enforcer instance.""" - if cls.enforcer is None: - cls.enforcer = cls.initialize_enforcer() - return cls.enforcer + if cls._enforcer is None: + cls._enforcer = cls._initialize_enforcer() + return cls._enforcer + + @classmethod + def get_enforcer(cls) -> FastEnforcer: + """Get the enforcer instance, creating it if needed. + + Returns: + FastEnforcer: The singleton enforcer instance. + """ + if cls._enforcer is None: + cls._enforcer = cls._initialize_enforcer() + return cls._enforcer - def initialize_enforcer(self) -> FastEnforcer: + @staticmethod + def _initialize_enforcer() -> FastEnforcer: """ Create and configure the Casbin FastEnforcer instance. diff --git a/openedx_authz/management/commands/load_policies.py b/openedx_authz/management/commands/load_policies.py index 36d34ad6..8275c566 100644 --- a/openedx_authz/management/commands/load_policies.py +++ b/openedx_authz/management/commands/load_policies.py @@ -12,9 +12,11 @@ from django.core.management.base import BaseCommand from openedx_authz import ROOT_DIRECTORY -from openedx_authz.engine.enforcer import enforcer as global_enforcer +from openedx_authz.engine.enforcer import AuthzEnforcer from openedx_authz.engine.utils import migrate_policy_between_enforcers +global_enforcer = AuthzEnforcer.get_enforcer() + class Command(BaseCommand): """Django management command to load policies into the authorization Django model. diff --git a/openedx_authz/tests/api/test_roles.py b/openedx_authz/tests/api/test_roles.py index 6a8df637..9a33c13d 100644 --- a/openedx_authz/tests/api/test_roles.py +++ b/openedx_authz/tests/api/test_roles.py @@ -31,9 +31,11 @@ get_subject_role_assignments_in_scope, unassign_role_from_subject_in_scope, ) -from openedx_authz.engine.enforcer import enforcer as global_enforcer +from openedx_authz.engine.enforcer import AuthzEnforcer from openedx_authz.engine.utils import migrate_policy_between_enforcers +global_enforcer = AuthzEnforcer.get_enforcer() + class BaseRolesTestCase(TestCase): """Base test case with helper methods for roles testing. diff --git a/openedx_authz/tests/test_enforcer.py b/openedx_authz/tests/test_enforcer.py index 3d3b033c..101a139c 100644 --- a/openedx_authz/tests/test_enforcer.py +++ b/openedx_authz/tests/test_enforcer.py @@ -10,10 +10,12 @@ from ddt import ddt from django.test import TestCase -from openedx_authz.engine.enforcer import enforcer as global_enforcer +from openedx_authz.engine.enforcer import AuthzEnforcer from openedx_authz.engine.filter import Filter from openedx_authz.engine.utils import migrate_policy_between_enforcers +global_enforcer = AuthzEnforcer.get_enforcer() + class PolicyLoadingTestSetupMixin(TestCase): """Mixin providing policy loading test utilities.""" From 3c728c4ac77452c1e39138c7f91a1617f25bb60d Mon Sep 17 00:00:00 2001 From: Maria Grimaldi Date: Fri, 10 Oct 2025 16:25:03 +0200 Subject: [PATCH 03/21] refactor: use common settings module as default --- manage.py | 2 +- openedx_authz/apps.py | 3 ++- openedx_authz/engine/enforcer.py | 1 - 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/manage.py b/manage.py index e6954dd0..8d8b9508 100644 --- a/manage.py +++ b/manage.py @@ -9,7 +9,7 @@ PWD = os.path.abspath(os.path.dirname(__file__)) if __name__ == "__main__": - os.environ.setdefault("DJANGO_SETTINGS_MODULE", "openedx_authz.settings.test") + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "openedx_authz.settings.common") sys.path.append(PWD) try: from django.core.management import execute_from_command_line diff --git a/openedx_authz/apps.py b/openedx_authz/apps.py index ea041df1..ff15c42c 100644 --- a/openedx_authz/apps.py +++ b/openedx_authz/apps.py @@ -4,6 +4,8 @@ from django.apps import AppConfig +from openedx_authz.engine.enforcer import AuthzEnforcer + class OpenedxAuthzConfig(AppConfig): """ @@ -42,6 +44,5 @@ class OpenedxAuthzConfig(AppConfig): def ready(self): """Initialization layer for the openedx_authz app.""" - from openedx_authz.engine.enforcer import AuthzEnforcer # Initialize the enforcer to ensure it's ready when the app starts AuthzEnforcer() diff --git a/openedx_authz/engine/enforcer.py b/openedx_authz/engine/enforcer.py index 149aaa35..fe56af48 100644 --- a/openedx_authz/engine/enforcer.py +++ b/openedx_authz/engine/enforcer.py @@ -21,7 +21,6 @@ from casbin import FastEnforcer from django.conf import settings -from openedx_authz.engine import adapter from openedx_authz.engine.adapter import ExtendedAdapter from openedx_authz.engine.watcher import Watcher From 986d014f400d6eb7a1ecaafd510bf9347c697087 Mon Sep 17 00:00:00 2001 From: Maria Grimaldi Date: Mon, 13 Oct 2025 10:57:03 +0200 Subject: [PATCH 04/21] refactor: move adding installed app when apps are ready --- openedx_authz/apps.py | 4 ++++ openedx_authz/settings/common.py | 3 --- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/openedx_authz/apps.py b/openedx_authz/apps.py index ff15c42c..f1fb3805 100644 --- a/openedx_authz/apps.py +++ b/openedx_authz/apps.py @@ -3,6 +3,7 @@ """ from django.apps import AppConfig +from django.conf import settings from openedx_authz.engine.enforcer import AuthzEnforcer @@ -45,4 +46,7 @@ class OpenedxAuthzConfig(AppConfig): def ready(self): """Initialization layer for the openedx_authz app.""" # Initialize the enforcer to ensure it's ready when the app starts + casbin_adapter_app = "casbin_adapter.apps.CasbinAdapterConfig" + if casbin_adapter_app not in settings.INSTALLED_APPS: + settings.INSTALLED_APPS.append(casbin_adapter_app) AuthzEnforcer() diff --git a/openedx_authz/settings/common.py b/openedx_authz/settings/common.py index 22feabd3..1f878422 100644 --- a/openedx_authz/settings/common.py +++ b/openedx_authz/settings/common.py @@ -17,9 +17,6 @@ def plugin_settings(settings): settings: The Django settings object """ # Add external third-party apps to INSTALLED_APPS - casbin_adapter_app = "casbin_adapter.apps.CasbinAdapterConfig" - if casbin_adapter_app not in settings.INSTALLED_APPS: - settings.INSTALLED_APPS.append(casbin_adapter_app) # Add Casbin configuration settings.CASBIN_MODEL = os.path.join(ROOT_DIRECTORY, "engine", "config", "model.conf") From 421cd21ad12e9faf66d3acfe7596bd010d66f9d8 Mon Sep 17 00:00:00 2001 From: Maria Grimaldi Date: Mon, 13 Oct 2025 12:28:56 +0200 Subject: [PATCH 05/21] refactor: move authzenforcer to ready hook --- openedx_authz/apps.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openedx_authz/apps.py b/openedx_authz/apps.py index f1fb3805..8fdb8c97 100644 --- a/openedx_authz/apps.py +++ b/openedx_authz/apps.py @@ -5,7 +5,6 @@ from django.apps import AppConfig from django.conf import settings -from openedx_authz.engine.enforcer import AuthzEnforcer class OpenedxAuthzConfig(AppConfig): @@ -46,6 +45,7 @@ class OpenedxAuthzConfig(AppConfig): def ready(self): """Initialization layer for the openedx_authz app.""" # Initialize the enforcer to ensure it's ready when the app starts + from openedx_authz.engine.enforcer import AuthzEnforcer casbin_adapter_app = "casbin_adapter.apps.CasbinAdapterConfig" if casbin_adapter_app not in settings.INSTALLED_APPS: settings.INSTALLED_APPS.append(casbin_adapter_app) From 015b3416542b50ee1a7e02cfb1203cfbc621e804 Mon Sep 17 00:00:00 2001 From: Maria Grimaldi Date: Mon, 13 Oct 2025 12:57:10 +0200 Subject: [PATCH 06/21] refactor: add casbin adapter to common to avoid build failing --- openedx_authz/settings/common.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/openedx_authz/settings/common.py b/openedx_authz/settings/common.py index 1f878422..22feabd3 100644 --- a/openedx_authz/settings/common.py +++ b/openedx_authz/settings/common.py @@ -17,6 +17,9 @@ def plugin_settings(settings): settings: The Django settings object """ # Add external third-party apps to INSTALLED_APPS + casbin_adapter_app = "casbin_adapter.apps.CasbinAdapterConfig" + if casbin_adapter_app not in settings.INSTALLED_APPS: + settings.INSTALLED_APPS.append(casbin_adapter_app) # Add Casbin configuration settings.CASBIN_MODEL = os.path.join(ROOT_DIRECTORY, "engine", "config", "model.conf") From 84f4cee72c7396a9b988474c7cc63e7c2c7ed84d Mon Sep 17 00:00:00 2001 From: Maria Grimaldi Date: Mon, 13 Oct 2025 13:03:28 +0200 Subject: [PATCH 07/21] refactor: include casbin in installed apps before loading models --- openedx_authz/apps.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openedx_authz/apps.py b/openedx_authz/apps.py index 8fdb8c97..766bc3d7 100644 --- a/openedx_authz/apps.py +++ b/openedx_authz/apps.py @@ -45,8 +45,8 @@ class OpenedxAuthzConfig(AppConfig): def ready(self): """Initialization layer for the openedx_authz app.""" # Initialize the enforcer to ensure it's ready when the app starts - from openedx_authz.engine.enforcer import AuthzEnforcer casbin_adapter_app = "casbin_adapter.apps.CasbinAdapterConfig" if casbin_adapter_app not in settings.INSTALLED_APPS: settings.INSTALLED_APPS.append(casbin_adapter_app) + from openedx_authz.engine.enforcer import AuthzEnforcer AuthzEnforcer() From 71503ea5002dc759efc2067b8ff40173087d375f Mon Sep 17 00:00:00 2001 From: Maria Grimaldi Date: Mon, 13 Oct 2025 13:03:34 +0200 Subject: [PATCH 08/21] Revert "refactor: add casbin adapter to common to avoid build failing" This reverts commit 688bb9ffe0ed107f238ff3163357ac2d4e47211e. --- openedx_authz/settings/common.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/openedx_authz/settings/common.py b/openedx_authz/settings/common.py index 22feabd3..1f878422 100644 --- a/openedx_authz/settings/common.py +++ b/openedx_authz/settings/common.py @@ -17,9 +17,6 @@ def plugin_settings(settings): settings: The Django settings object """ # Add external third-party apps to INSTALLED_APPS - casbin_adapter_app = "casbin_adapter.apps.CasbinAdapterConfig" - if casbin_adapter_app not in settings.INSTALLED_APPS: - settings.INSTALLED_APPS.append(casbin_adapter_app) # Add Casbin configuration settings.CASBIN_MODEL = os.path.join(ROOT_DIRECTORY, "engine", "config", "model.conf") From 0af187b22f7b85ccc79bd0f7f5154eeddb5454af Mon Sep 17 00:00:00 2001 From: Maria Grimaldi Date: Mon, 13 Oct 2025 13:13:41 +0200 Subject: [PATCH 09/21] refactor: move installed apps configuration to common settings --- openedx_authz/apps.py | 3 --- openedx_authz/settings/common.py | 4 +++- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/openedx_authz/apps.py b/openedx_authz/apps.py index 766bc3d7..45adbb38 100644 --- a/openedx_authz/apps.py +++ b/openedx_authz/apps.py @@ -45,8 +45,5 @@ class OpenedxAuthzConfig(AppConfig): def ready(self): """Initialization layer for the openedx_authz app.""" # Initialize the enforcer to ensure it's ready when the app starts - casbin_adapter_app = "casbin_adapter.apps.CasbinAdapterConfig" - if casbin_adapter_app not in settings.INSTALLED_APPS: - settings.INSTALLED_APPS.append(casbin_adapter_app) from openedx_authz.engine.enforcer import AuthzEnforcer AuthzEnforcer() diff --git a/openedx_authz/settings/common.py b/openedx_authz/settings/common.py index 1f878422..10895a40 100644 --- a/openedx_authz/settings/common.py +++ b/openedx_authz/settings/common.py @@ -17,7 +17,9 @@ def plugin_settings(settings): settings: The Django settings object """ # Add external third-party apps to INSTALLED_APPS - + casbin_adapter_app = "casbin_adapter.apps.CasbinAdapterConfig" + if casbin_adapter_app not in settings.INSTALLED_APPS: + settings.INSTALLED_APPS.append(casbin_adapter_app) # Add Casbin configuration settings.CASBIN_MODEL = os.path.join(ROOT_DIRECTORY, "engine", "config", "model.conf") settings.CASBIN_WATCHER_ENABLED = True From cb06ea2d019eddb757565db4cfeff9587fc48028 Mon Sep 17 00:00:00 2001 From: Maria Grimaldi Date: Mon, 13 Oct 2025 13:34:59 +0200 Subject: [PATCH 10/21] refactor: skip improperly configured issue when apps are not fully config --- openedx_authz/apps.py | 12 ++++++++---- openedx_authz/settings/common.py | 6 ++++-- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/openedx_authz/apps.py b/openedx_authz/apps.py index 45adbb38..73229c5c 100644 --- a/openedx_authz/apps.py +++ b/openedx_authz/apps.py @@ -3,8 +3,7 @@ """ from django.apps import AppConfig -from django.conf import settings - +from django.core.exceptions import ImproperlyConfigured class OpenedxAuthzConfig(AppConfig): @@ -45,5 +44,10 @@ class OpenedxAuthzConfig(AppConfig): def ready(self): """Initialization layer for the openedx_authz app.""" # Initialize the enforcer to ensure it's ready when the app starts - from openedx_authz.engine.enforcer import AuthzEnforcer - AuthzEnforcer() + try: + from openedx_authz.engine.enforcer import AuthzEnforcer + AuthzEnforcer() + except ImproperlyConfigured: + # The app might not be fully configured yet (e.g., during migrations). + # In such cases, we skip the enforcer initialization. + pass diff --git a/openedx_authz/settings/common.py b/openedx_authz/settings/common.py index 10895a40..7f9f7e37 100644 --- a/openedx_authz/settings/common.py +++ b/openedx_authz/settings/common.py @@ -21,8 +21,10 @@ def plugin_settings(settings): if casbin_adapter_app not in settings.INSTALLED_APPS: settings.INSTALLED_APPS.append(casbin_adapter_app) # Add Casbin configuration - settings.CASBIN_MODEL = os.path.join(ROOT_DIRECTORY, "engine", "config", "model.conf") - settings.CASBIN_WATCHER_ENABLED = True + settings.CASBIN_MODEL = os.path.join( + ROOT_DIRECTORY, "engine", "config", "model.conf" + ) + settings.CASBIN_WATCHER_ENABLED = False # TODO: Replace with a more dynamic configuration # Redis host and port are temporarily loaded here for the MVP settings.REDIS_HOST = "redis" From 820c8d40ac8290b72115530e49ed5ce9c3a85d6d Mon Sep 17 00:00:00 2001 From: Maria Grimaldi Date: Mon, 13 Oct 2025 13:55:07 +0200 Subject: [PATCH 11/21] refactor: override casbin adapter config for a safe load --- openedx_authz/engine/apps.py | 20 ++++++++++++++++++++ openedx_authz/settings/common.py | 2 +- 2 files changed, 21 insertions(+), 1 deletion(-) create mode 100644 openedx_authz/engine/apps.py diff --git a/openedx_authz/engine/apps.py b/openedx_authz/engine/apps.py new file mode 100644 index 00000000..bc98b6ab --- /dev/null +++ b/openedx_authz/engine/apps.py @@ -0,0 +1,20 @@ +from django.apps import AppConfig +from django.conf import settings +from django.core.exceptions import ImproperlyConfigured + + +class CasbinAdapterConfig(AppConfig): + name = "casbin_adapter" + + def ready(self): + """Initialization layer for the casbin_adapter app.""" + + try: + from casbin_adapter.enforcer import initialize_enforcer + + db_alias = getattr(settings, "CASBIN_DB_ALIAS", "default") + initialize_enforcer(db_alias) + except ImproperlyConfigured: + # The app might not be fully configured yet (e.g., during migrations). + # In such cases, we skip the enforcer initialization. + pass diff --git a/openedx_authz/settings/common.py b/openedx_authz/settings/common.py index 7f9f7e37..98620ca7 100644 --- a/openedx_authz/settings/common.py +++ b/openedx_authz/settings/common.py @@ -17,7 +17,7 @@ def plugin_settings(settings): settings: The Django settings object """ # Add external third-party apps to INSTALLED_APPS - casbin_adapter_app = "casbin_adapter.apps.CasbinAdapterConfig" + casbin_adapter_app = "openedx_authz.engine.apps.CasbinAdapterConfig" if casbin_adapter_app not in settings.INSTALLED_APPS: settings.INSTALLED_APPS.append(casbin_adapter_app) # Add Casbin configuration From 4a7705021ebd828ef6b336da53be791d5ff159f0 Mon Sep 17 00:00:00 2001 From: Maria Grimaldi Date: Mon, 13 Oct 2025 14:16:22 +0200 Subject: [PATCH 12/21] refactor: go back to using test settings but fix import order --- manage.py | 2 +- openedx_authz/settings/test.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/manage.py b/manage.py index 8d8b9508..e6954dd0 100644 --- a/manage.py +++ b/manage.py @@ -9,7 +9,7 @@ PWD = os.path.abspath(os.path.dirname(__file__)) if __name__ == "__main__": - os.environ.setdefault("DJANGO_SETTINGS_MODULE", "openedx_authz.settings.common") + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "openedx_authz.settings.test") sys.path.append(PWD) try: from django.core.management import execute_from_command_line diff --git a/openedx_authz/settings/test.py b/openedx_authz/settings/test.py index 8e2ea3e7..fd30bb51 100644 --- a/openedx_authz/settings/test.py +++ b/openedx_authz/settings/test.py @@ -28,8 +28,8 @@ "django.contrib.contenttypes", "django.contrib.messages", "django.contrib.sessions", + "openedx_authz.engine.apps.CasbinAdapterConfig", "openedx_authz.apps.OpenedxAuthzConfig", - "casbin_adapter.apps.CasbinAdapterConfig", ) MIDDLEWARE = [ From 9ceaa07fe2a745eca91cc2301a923887fb065efc Mon Sep 17 00:00:00 2001 From: Maria Grimaldi Date: Mon, 13 Oct 2025 15:01:00 +0200 Subject: [PATCH 13/21] refactor: initialize enforcer when firstly use to avoid raising errors while initializing application --- openedx_authz/api/permissions.py | 11 +++--- openedx_authz/api/roles.py | 34 ++++++++----------- openedx_authz/apps.py | 12 +++---- openedx_authz/engine/apps.py | 14 +++----- openedx_authz/engine/enforcer.py | 20 ++++++++--- .../management/commands/load_policies.py | 3 +- openedx_authz/tests/api/test_roles.py | 12 ++++++- openedx_authz/tests/test_enforcer.py | 18 ++++++++-- 8 files changed, 72 insertions(+), 52 deletions(-) diff --git a/openedx_authz/api/permissions.py b/openedx_authz/api/permissions.py index 346bc9dc..46d75302 100644 --- a/openedx_authz/api/permissions.py +++ b/openedx_authz/api/permissions.py @@ -8,8 +8,6 @@ from openedx_authz.api.data import ActionData, PermissionData, PolicyIndex, ScopeData, SubjectData from openedx_authz.engine.enforcer import AuthzEnforcer -enforcer = AuthzEnforcer.get_enforcer() - __all__ = [ "get_permission_from_policy", "get_all_permissions_in_scope", @@ -44,7 +42,9 @@ def get_all_permissions_in_scope(scope: ScopeData) -> list[PermissionData]: Returns: list of PermissionData: A list of PermissionData objects associated with the given scope. """ - actions = enforcer.get_filtered_policy(PolicyIndex.SCOPE.value, scope.namespaced_key) + actions = AuthzEnforcer.get_enforcer().get_filtered_policy( + PolicyIndex.SCOPE.value, scope.namespaced_key + ) return [get_permission_from_policy(action) for action in actions] @@ -63,5 +63,6 @@ def is_subject_allowed( Returns: bool: True if the subject has the specified permission in the scope, False otherwise. """ - enforcer.load_policy() - return enforcer.enforce(subject.namespaced_key, action.namespaced_key, scope.namespaced_key) + return AuthzEnforcer.get_enforcer().enforce( + subject.namespaced_key, action.namespaced_key, scope.namespaced_key + ) diff --git a/openedx_authz/api/roles.py b/openedx_authz/api/roles.py index 58794bd6..02a54e64 100644 --- a/openedx_authz/api/roles.py +++ b/openedx_authz/api/roles.py @@ -22,7 +22,6 @@ from openedx_authz.api.permissions import get_permission_from_policy from openedx_authz.engine.enforcer import AuthzEnforcer -enforcer = AuthzEnforcer.get_enforcer() __all__ = [ "get_permissions_for_single_role", @@ -61,7 +60,7 @@ def get_permissions_for_single_role( Returns: list[PermissionData]: A list of PermissionData objects associated with the given role. """ - policies = enforcer.get_implicit_permissions_for_user(role.namespaced_key) + policies = AuthzEnforcer.get_enforcer().get_implicit_permissions_for_user(role.namespaced_key) return [get_permission_from_policy(policy) for policy in policies] @@ -116,8 +115,7 @@ def get_permissions_for_active_roles_in_scope( dict[str, list[PermissionData]]: A dictionary mapping the role external_key to its permissions and scopes. """ - enforcer.load_policy() - filtered_policy = enforcer.get_filtered_grouping_policy( + filtered_policy = AuthzEnforcer.get_enforcer().get_filtered_grouping_policy( GroupingPolicyIndex.SCOPE.value, scope.namespaced_key ) @@ -148,8 +146,7 @@ def get_role_definitions_in_scope(scope: ScopeData) -> list[RoleData]: Returns: list[Role]: A list of roles. """ - enforcer.load_policy() - policy_filtered = enforcer.get_filtered_policy( + policy_filtered = AuthzEnforcer.get_enforcer().get_filtered_policy( PolicyIndex.SCOPE.value, scope.namespaced_key ) @@ -182,7 +179,7 @@ def get_all_roles_names() -> list[str]: Returns: list[str]: A list of role names. """ - return enforcer.get_all_subjects() + return AuthzEnforcer.get_enforcer().get_all_subjects() def get_all_roles_in_scope(scope: ScopeData) -> list[list[str]]: @@ -194,8 +191,7 @@ def get_all_roles_in_scope(scope: ScopeData) -> list[list[str]]: Returns: list[list[str]]: A list of policies in the specified scope. """ - enforcer.load_policy() - return enforcer.get_filtered_grouping_policy( + return AuthzEnforcer.get_enforcer().get_filtered_grouping_policy( GroupingPolicyIndex.SCOPE.value, scope.namespaced_key ) @@ -213,8 +209,7 @@ def assign_role_to_subject_in_scope( Returns: bool: True if the role was assigned successfully, False otherwise. """ - enforcer.load_policy() - return enforcer.add_role_for_user_in_domain( + AuthzEnforcer.get_enforcer().add_role_for_user_in_domain( subject.namespaced_key, role.namespaced_key, scope.namespaced_key, @@ -247,8 +242,7 @@ def unassign_role_from_subject_in_scope( Returns: bool: True if the role was unassigned successfully, False otherwise. """ - enforcer.load_policy() - return enforcer.delete_roles_for_user_in_domain( + AuthzEnforcer.get_enforcer().delete_roles_for_user_in_domain( subject.namespaced_key, role.namespaced_key, scope.namespaced_key ) @@ -277,7 +271,7 @@ def get_subject_role_assignments(subject: SubjectData) -> list[RoleAssignmentDat list[RoleAssignmentData]: A list of role assignments for the subject. """ role_assignments = [] - for policy in enforcer.get_filtered_grouping_policy( + for policy in AuthzEnforcer.get_enforcer().get_filtered_grouping_policy( GroupingPolicyIndex.SUBJECT.value, subject.namespaced_key ): role = RoleData(namespaced_key=policy[GroupingPolicyIndex.ROLE.value]) @@ -305,10 +299,10 @@ def get_subject_role_assignments_in_scope( Returns: list[RoleAssignmentData]: A list of role assignments for the subject in the scope. """ - enforcer.load_policy() + AuthzEnforcer.get_enforcer().load_policy() # TODO: we still need to get the remaining data for the role like email, etc role_assignments = [] - for namespaced_key in enforcer.get_roles_for_user_in_domain( + for namespaced_key in AuthzEnforcer.get_enforcer().get_roles_for_user_in_domain( subject.namespaced_key, scope.namespaced_key ): role = RoleData(namespaced_key=namespaced_key) @@ -340,7 +334,7 @@ def get_subject_role_assignments_for_role_in_scope( list[RoleAssignmentData]: A list of subjects assigned to the specified role in the specified scope. """ role_assignments = [] - for subject in enforcer.get_users_for_role_in_domain( + for subject in AuthzEnforcer.get_enforcer().get_users_for_role_in_domain( role.namespaced_key, scope.namespaced_key ): if subject.startswith(f"{RoleData.NAMESPACE}{RoleData.SEPARATOR}"): @@ -404,6 +398,8 @@ def get_subjects_for_role(role: RoleData) -> list[SubjectData]: Returns: list[SubjectData]: A list of subjects assigned to the specified role. """ - enforcer.load_policy() - policies = enforcer.get_filtered_grouping_policy(GroupingPolicyIndex.ROLE.value, role.namespaced_key) + policies = AuthzEnforcer.get_enforcer().get_filtered_grouping_policy( + GroupingPolicyIndex.ROLE.value, + role.namespaced_key + ) return [SubjectData(namespaced_key=policy[GroupingPolicyIndex.SUBJECT.value]) for policy in policies] diff --git a/openedx_authz/apps.py b/openedx_authz/apps.py index 73229c5c..df3e8fd4 100644 --- a/openedx_authz/apps.py +++ b/openedx_authz/apps.py @@ -43,11 +43,7 @@ class OpenedxAuthzConfig(AppConfig): def ready(self): """Initialization layer for the openedx_authz app.""" - # Initialize the enforcer to ensure it's ready when the app starts - try: - from openedx_authz.engine.enforcer import AuthzEnforcer - AuthzEnforcer() - except ImproperlyConfigured: - # The app might not be fully configured yet (e.g., during migrations). - # In such cases, we skip the enforcer initialization. - pass + # DO NOT initialize the enforcer here to avoid issues when + # apps are not fully loaded (e.g., while pulling translations). + # It's best to lazy load the enforcer when needed it's first used. + pass diff --git a/openedx_authz/engine/apps.py b/openedx_authz/engine/apps.py index bc98b6ab..42af5b8b 100644 --- a/openedx_authz/engine/apps.py +++ b/openedx_authz/engine/apps.py @@ -8,13 +8,7 @@ class CasbinAdapterConfig(AppConfig): def ready(self): """Initialization layer for the casbin_adapter app.""" - - try: - from casbin_adapter.enforcer import initialize_enforcer - - db_alias = getattr(settings, "CASBIN_DB_ALIAS", "default") - initialize_enforcer(db_alias) - except ImproperlyConfigured: - # The app might not be fully configured yet (e.g., during migrations). - # In such cases, we skip the enforcer initialization. - pass + # DO NOT initialize the enforcer here to avoid issues when + # apps are not fully loaded (e.g., while pulling translations). + # It's best to lazy load the enforcer when needed it's first used. + pass diff --git a/openedx_authz/engine/enforcer.py b/openedx_authz/engine/enforcer.py index fe56af48..7d859404 100644 --- a/openedx_authz/engine/enforcer.py +++ b/openedx_authz/engine/enforcer.py @@ -23,6 +23,7 @@ from openedx_authz.engine.adapter import ExtendedAdapter from openedx_authz.engine.watcher import Watcher +from casbin_adapter.enforcer import initialize_enforcer logger = logging.getLogger(__name__) @@ -33,9 +34,17 @@ class AuthzEnforcer: Ensures a single enforcer instance is created safely and configured with the ExtendedAdapter and Redis watcher for policy management and synchronization. - Usage: + There are two main use cases for this class: + 1. Directly get the enforcer instance and initialize it if needed: + from openedx_authz.engine.enforcer import AuthzEnforcer enforcer = AuthzEnforcer.get_enforcer() allowed = enforcer.enforce(user, resource, action) + 2. Instantiate the class to get the singleton enforcer instance: + from openedx_authz.engine.enforcer import AuthzEnforcer + enforcer = AuthzEnforcer() + allowed = enforcer.get_enforcer().enforce(user, resource, action) + + Any of the two approaches will yield the same singleton enforcer instance. """ _enforcer = None @@ -62,13 +71,16 @@ def _initialize_enforcer() -> FastEnforcer: """ Create and configure the Casbin FastEnforcer instance. - This function initializes the Casbin FastEnforcer with the ExtendedAdapter - for database-backed policy storage and sets up the Redis watcher for - real-time policy synchronization. + This method initializes the FastEnforcer with the ExtendedAdapter + for database policy storage and sets up the Redis watcher for real-time + policy synchronization if the Watcher is available. It also initializes + the enforcer with the specified database alias from settings. Returns: FastEnforcer: Configured Casbin enforcer with adapter and watcher """ + db_alias = getattr(settings, "CASBIN_DB_ALIAS", "default") + initialize_enforcer(db_alias) adapter = ExtendedAdapter() enforcer = FastEnforcer(settings.CASBIN_MODEL, adapter, enable_log=True) enforcer.enable_auto_save(True) diff --git a/openedx_authz/management/commands/load_policies.py b/openedx_authz/management/commands/load_policies.py index 8275c566..5cf39c2e 100644 --- a/openedx_authz/management/commands/load_policies.py +++ b/openedx_authz/management/commands/load_policies.py @@ -15,7 +15,6 @@ from openedx_authz.engine.enforcer import AuthzEnforcer from openedx_authz.engine.utils import migrate_policy_between_enforcers -global_enforcer = AuthzEnforcer.get_enforcer() class Command(BaseCommand): @@ -76,7 +75,7 @@ def handle(self, *args, **options): ) source_enforcer = casbin.Enforcer(model_file_path, policy_file_path) - self.migrate_policies(source_enforcer, global_enforcer) + self.migrate_policies(source_enforcer, AuthzEnforcer.get_enforcer()) def migrate_policies(self, source_enforcer, target_enforcer): """Migrate policies from the source enforcer to the target enforcer. diff --git a/openedx_authz/tests/api/test_roles.py b/openedx_authz/tests/api/test_roles.py index 9a33c13d..aabecf10 100644 --- a/openedx_authz/tests/api/test_roles.py +++ b/openedx_authz/tests/api/test_roles.py @@ -34,7 +34,6 @@ from openedx_authz.engine.enforcer import AuthzEnforcer from openedx_authz.engine.utils import migrate_policy_between_enforcers -global_enforcer = AuthzEnforcer.get_enforcer() class BaseRolesTestCase(TestCase): @@ -52,6 +51,7 @@ def _seed_database_with_policies(cls): This simulates the one-time database seeding that would happen during application deployment, separate from the runtime policy loading. """ + global_enforcer = AuthzEnforcer.get_enforcer() global_enforcer.load_policy() migrate_policy_between_enforcers( source_enforcer=casbin.Enforcer( @@ -243,6 +243,16 @@ def setUpClass(cls): ] cls._assign_roles_to_users(assignments=assignments) + def setUp(self): + """Set up test environment.""" + super().setUp() + AuthzEnforcer.get_enforcer().load_policy() # Load policies before each test to simulate fresh start + + def tearDown(self): + """Clean up after each test to ensure isolation.""" + super().tearDown() + AuthzEnforcer.get_enforcer().clear_policy() # Clear policies after each test to ensure isolation + @ddt class TestRolesAPI(RolesTestSetupMixin): diff --git a/openedx_authz/tests/test_enforcer.py b/openedx_authz/tests/test_enforcer.py index 101a139c..83cb1f14 100644 --- a/openedx_authz/tests/test_enforcer.py +++ b/openedx_authz/tests/test_enforcer.py @@ -14,8 +14,6 @@ from openedx_authz.engine.filter import Filter from openedx_authz.engine.utils import migrate_policy_between_enforcers -global_enforcer = AuthzEnforcer.get_enforcer() - class PolicyLoadingTestSetupMixin(TestCase): """Mixin providing policy loading test utilities.""" @@ -65,6 +63,7 @@ def _seed_database_with_policies(self): during application deployment, separate from runtime policy loading. """ # Always start with completely clean state + global_enforcer = AuthzEnforcer.get_enforcer() global_enforcer.clear_policy() migrate_policy_between_enforcers( @@ -87,6 +86,7 @@ def _load_policies_for_scope(self, scope: str = None): scope: The scope to load policies for (e.g., 'lib^*' for all libraries). If None, loads all policies using load_policy(). """ + global_enforcer = AuthzEnforcer.get_enforcer() if scope is None: global_enforcer.load_policy() else: @@ -99,6 +99,7 @@ def _load_policies_for_user_context(self, scopes: list[str] = None): Args: scopes: List of scopes the user is operating in. """ + global_enforcer = AuthzEnforcer.get_enforcer() global_enforcer.clear_policy() if scopes: @@ -116,6 +117,7 @@ def _load_policies_for_role_management(self, role_name: str = None): Args: role_name: Specific role to load policies for, if any. """ + global_enforcer = AuthzEnforcer.get_enforcer() global_enforcer.clear_policy() if role_name: @@ -131,6 +133,7 @@ def _add_test_policies_for_multiple_scopes(self): This adds course and organization policies in addition to existing library policies to create a realistic multi-scope environment. """ + global_enforcer = AuthzEnforcer.get_enforcer() test_policies = [ # Course policies ["role^course_instructor", "act^edit_course", "course^*", "allow"], @@ -172,7 +175,7 @@ def setUp(self): def tearDown(self): """Clean up after each test to ensure isolation.""" - global_enforcer.clear_policy() + AuthzEnforcer.get_enforcer().clear_policy() super().tearDown() @ddt_data( @@ -191,6 +194,7 @@ def test_scope_based_policy_loading(self, scope): - Only scope-relevant policies are loaded - Policy count matches expected for scope """ + global_enforcer = AuthzEnforcer.get_enforcer() expected_policy_count = self._count_policies_in_file(scope_pattern=scope) initial_policy_count = len(global_enforcer.get_policy()) @@ -221,6 +225,7 @@ def test_user_context_policy_loading(self, user_scopes): - Policies are loaded for user's scopes - Policy count is reasonable for context """ + global_enforcer = AuthzEnforcer.get_enforcer() initial_policy_count = len(global_enforcer.get_policy()) self._load_policies_for_user_context(user_scopes) @@ -241,6 +246,7 @@ def test_role_specific_policy_loading(self, role_name): - Role-specific policies are loaded - Loaded policies contain expected role """ + global_enforcer = AuthzEnforcer.get_enforcer() initial_policy_count = len(global_enforcer.get_policy()) self._load_policies_for_role_management(role_name) @@ -263,6 +269,7 @@ def test_policy_loading_lifecycle(self): - Policy counts change appropriately between stages - No policies exist at startup """ + global_enforcer = AuthzEnforcer.get_enforcer() startup_policy_count = len(global_enforcer.get_policy()) self.assertEqual(startup_policy_count, 0) @@ -293,6 +300,7 @@ def test_empty_enforcer_behavior(self): - Policy queries return empty results - No enforcement decisions are possible """ + global_enforcer = AuthzEnforcer.get_enforcer() initial_policy_count = len(global_enforcer.get_policy()) all_policies = global_enforcer.get_policy() all_grouping_policies = global_enforcer.get_grouping_policy() @@ -320,6 +328,7 @@ def test_filtered_policy_loading_variations(self, policy_filter): - Filtered loading works without errors - Appropriate policies are loaded based on filter """ + global_enforcer = AuthzEnforcer.get_enforcer() initial_policy_count = len(global_enforcer.get_policy()) global_enforcer.clear_policy() @@ -337,6 +346,7 @@ def test_policy_clear_and_reload(self): - Cleared enforcer has no policies - Reloading produces same count as initial load """ + global_enforcer = AuthzEnforcer.get_enforcer() self._load_policies_for_scope("lib^*") initial_load_count = len(global_enforcer.get_policy()) @@ -360,6 +370,7 @@ def test_filtered_loading_by_role(self, role_name): - Filtered count matches policies in file for that role - All loaded policies contain the specified role """ + global_enforcer = AuthzEnforcer.get_enforcer() expected_count = self._count_policies_in_file(role=role_name) self._load_policies_for_role_management(role_name) @@ -377,6 +388,7 @@ def test_multi_scope_filtering(self): - Combined scope filter loads sum of individual scopes - Total load equals sum of all scope policies """ + global_enforcer = AuthzEnforcer.get_enforcer() lib_scope = "lib^*" course_scope = "course^*" org_scope = "org^*" From 336d7d7fefa181588a33371b41cc01ce501bdb04 Mon Sep 17 00:00:00 2001 From: Maria Grimaldi Date: Mon, 13 Oct 2025 15:11:35 +0200 Subject: [PATCH 14/21] docs: add inline doc explaining why initialize enforcer --- openedx_authz/engine/enforcer.py | 26 +++++++++++++++++++------- 1 file changed, 19 insertions(+), 7 deletions(-) diff --git a/openedx_authz/engine/enforcer.py b/openedx_authz/engine/enforcer.py index 7d859404..6b9c3538 100644 --- a/openedx_authz/engine/enforcer.py +++ b/openedx_authz/engine/enforcer.py @@ -80,16 +80,28 @@ def _initialize_enforcer() -> FastEnforcer: FastEnforcer: Configured Casbin enforcer with adapter and watcher """ db_alias = getattr(settings, "CASBIN_DB_ALIAS", "default") - initialize_enforcer(db_alias) + + try: + # Initialize the enforcer with the specified database alias to set up the adapter. + # Best to lazy load it when it's first used to ensure the database is ready and avoid + # issues when the app is not fully loaded (e.g., while pulling translations, etc.). + initialize_enforcer(db_alias) + except Exception as e: # pylint: disable=broad-exception-caught + logger.error(f"Failed to initialize Casbin enforcer with DB alias '{db_alias}': {e}") + raise + adapter = ExtendedAdapter() enforcer = FastEnforcer(settings.CASBIN_MODEL, adapter, enable_log=True) enforcer.enable_auto_save(True) - if Watcher: - try: - enforcer.set_watcher(Watcher) - logger.info("Watcher successfully set on Casbin enforcer") - except Exception as e: # pylint: disable=broad-exception-caught - logger.error(f"Failed to set watcher on Casbin enforcer: {e}") + if not Watcher: + logger.warning("Redis configuration not completed successfully. Watcher is disabled.") + return enforcer + + try: + enforcer.set_watcher(Watcher) + logger.info("Watcher successfully set on Casbin enforcer") + except Exception as e: # pylint: disable=broad-exception-caught + logger.error(f"Failed to set watcher on Casbin enforcer: {e}") return enforcer From b2bb892a765165e6f66ef0dfcf31361382b0ee3b Mon Sep 17 00:00:00 2001 From: Maria Grimaldi Date: Tue, 14 Oct 2025 12:57:54 +0200 Subject: [PATCH 15/21] refactor: address rebase failure --- openedx_authz/api/roles.py | 32 ++++++++++++++++++--------- openedx_authz/tests/api/test_roles.py | 10 --------- 2 files changed, 21 insertions(+), 21 deletions(-) diff --git a/openedx_authz/api/roles.py b/openedx_authz/api/roles.py index 02a54e64..a973fc4c 100644 --- a/openedx_authz/api/roles.py +++ b/openedx_authz/api/roles.py @@ -115,7 +115,9 @@ def get_permissions_for_active_roles_in_scope( dict[str, list[PermissionData]]: A dictionary mapping the role external_key to its permissions and scopes. """ - filtered_policy = AuthzEnforcer.get_enforcer().get_filtered_grouping_policy( + enforcer = AuthzEnforcer.get_enforcer() + enforcer.load_policy() + filtered_policy = enforcer.get_filtered_grouping_policy( GroupingPolicyIndex.SCOPE.value, scope.namespaced_key ) @@ -146,7 +148,9 @@ def get_role_definitions_in_scope(scope: ScopeData) -> list[RoleData]: Returns: list[Role]: A list of roles. """ - policy_filtered = AuthzEnforcer.get_enforcer().get_filtered_policy( + enforcer = AuthzEnforcer.get_enforcer() + enforcer.load_policy() + policy_filtered = enforcer.get_filtered_policy( PolicyIndex.SCOPE.value, scope.namespaced_key ) @@ -191,7 +195,9 @@ def get_all_roles_in_scope(scope: ScopeData) -> list[list[str]]: Returns: list[list[str]]: A list of policies in the specified scope. """ - return AuthzEnforcer.get_enforcer().get_filtered_grouping_policy( + enforcer = AuthzEnforcer.get_enforcer() + enforcer.load_policy() + return enforcer.get_filtered_grouping_policy( GroupingPolicyIndex.SCOPE.value, scope.namespaced_key ) @@ -209,7 +215,9 @@ def assign_role_to_subject_in_scope( Returns: bool: True if the role was assigned successfully, False otherwise. """ - AuthzEnforcer.get_enforcer().add_role_for_user_in_domain( + enforcer = AuthzEnforcer.get_enforcer() + enforcer.load_policy() + return enforcer.add_role_for_user_in_domain( subject.namespaced_key, role.namespaced_key, scope.namespaced_key, @@ -242,7 +250,9 @@ def unassign_role_from_subject_in_scope( Returns: bool: True if the role was unassigned successfully, False otherwise. """ - AuthzEnforcer.get_enforcer().delete_roles_for_user_in_domain( + enforcer = AuthzEnforcer.get_enforcer() + enforcer.load_policy() + return enforcer.delete_roles_for_user_in_domain( subject.namespaced_key, role.namespaced_key, scope.namespaced_key ) @@ -299,10 +309,11 @@ def get_subject_role_assignments_in_scope( Returns: list[RoleAssignmentData]: A list of role assignments for the subject in the scope. """ - AuthzEnforcer.get_enforcer().load_policy() + enforcer = AuthzEnforcer.get_enforcer() + enforcer.load_policy() # TODO: we still need to get the remaining data for the role like email, etc role_assignments = [] - for namespaced_key in AuthzEnforcer.get_enforcer().get_roles_for_user_in_domain( + for namespaced_key in enforcer.get_roles_for_user_in_domain( subject.namespaced_key, scope.namespaced_key ): role = RoleData(namespaced_key=namespaced_key) @@ -398,8 +409,7 @@ def get_subjects_for_role(role: RoleData) -> list[SubjectData]: Returns: list[SubjectData]: A list of subjects assigned to the specified role. """ - policies = AuthzEnforcer.get_enforcer().get_filtered_grouping_policy( - GroupingPolicyIndex.ROLE.value, - role.namespaced_key - ) + enforcer = AuthzEnforcer.get_enforcer() + enforcer.load_policy() + policies = enforcer.get_filtered_grouping_policy(GroupingPolicyIndex.ROLE.value, role.namespaced_key) return [SubjectData(namespaced_key=policy[GroupingPolicyIndex.SUBJECT.value]) for policy in policies] diff --git a/openedx_authz/tests/api/test_roles.py b/openedx_authz/tests/api/test_roles.py index aabecf10..93429b7f 100644 --- a/openedx_authz/tests/api/test_roles.py +++ b/openedx_authz/tests/api/test_roles.py @@ -98,16 +98,6 @@ def setUpClass(cls): super().setUpClass() cls._seed_database_with_policies() - def setUp(self): - """Set up test environment.""" - super().setUp() - global_enforcer.load_policy() # Load policies before each test to simulate fresh start - - def tearDown(self): - """Clean up after each test to ensure isolation.""" - super().tearDown() - global_enforcer.clear_policy() # Clear policies after each test to ensure isolation - class RolesTestSetupMixin(BaseRolesTestCase): """Test case with comprehensive role assignments for general roles testing.""" From 5996ef9b7b7faec0c4b7258d86282132b6d6d700 Mon Sep 17 00:00:00 2001 From: Maria Grimaldi Date: Tue, 14 Oct 2025 13:06:19 +0200 Subject: [PATCH 16/21] refactor: address quality issues --- openedx_authz/api/roles.py | 1 - openedx_authz/apps.py | 8 -------- openedx_authz/engine/apps.py | 14 ++++++++++---- openedx_authz/engine/enforcer.py | 4 ++-- openedx_authz/management/commands/load_policies.py | 1 - openedx_authz/tests/api/test_roles.py | 1 - 6 files changed, 12 insertions(+), 17 deletions(-) diff --git a/openedx_authz/api/roles.py b/openedx_authz/api/roles.py index a973fc4c..c5ce62ba 100644 --- a/openedx_authz/api/roles.py +++ b/openedx_authz/api/roles.py @@ -22,7 +22,6 @@ from openedx_authz.api.permissions import get_permission_from_policy from openedx_authz.engine.enforcer import AuthzEnforcer - __all__ = [ "get_permissions_for_single_role", "get_permissions_for_roles", diff --git a/openedx_authz/apps.py b/openedx_authz/apps.py index df3e8fd4..7de0d16b 100644 --- a/openedx_authz/apps.py +++ b/openedx_authz/apps.py @@ -3,7 +3,6 @@ """ from django.apps import AppConfig -from django.core.exceptions import ImproperlyConfigured class OpenedxAuthzConfig(AppConfig): @@ -40,10 +39,3 @@ class OpenedxAuthzConfig(AppConfig): }, }, } - - def ready(self): - """Initialization layer for the openedx_authz app.""" - # DO NOT initialize the enforcer here to avoid issues when - # apps are not fully loaded (e.g., while pulling translations). - # It's best to lazy load the enforcer when needed it's first used. - pass diff --git a/openedx_authz/engine/apps.py b/openedx_authz/engine/apps.py index 42af5b8b..f62bdf17 100644 --- a/openedx_authz/engine/apps.py +++ b/openedx_authz/engine/apps.py @@ -1,14 +1,20 @@ +"""Initialization for the casbin_adapter Django application. + +This overrides the default AppConfig to avoid making queries to the database +when the app is not fully loaded (e.g., while pulling translations). Moved +the initialization of the enforcer to a lazy load when it's first used. + +See openedx_authz/engine/enforcer.py for the enforcer implementation. +""" + from django.apps import AppConfig -from django.conf import settings -from django.core.exceptions import ImproperlyConfigured class CasbinAdapterConfig(AppConfig): name = "casbin_adapter" def ready(self): - """Initialization layer for the casbin_adapter app.""" + """Initialize the casbin_adapter app.""" # DO NOT initialize the enforcer here to avoid issues when # apps are not fully loaded (e.g., while pulling translations). # It's best to lazy load the enforcer when needed it's first used. - pass diff --git a/openedx_authz/engine/enforcer.py b/openedx_authz/engine/enforcer.py index 6b9c3538..e30b3277 100644 --- a/openedx_authz/engine/enforcer.py +++ b/openedx_authz/engine/enforcer.py @@ -19,11 +19,11 @@ import logging from casbin import FastEnforcer +from casbin_adapter.enforcer import initialize_enforcer from django.conf import settings from openedx_authz.engine.adapter import ExtendedAdapter from openedx_authz.engine.watcher import Watcher -from casbin_adapter.enforcer import initialize_enforcer logger = logging.getLogger(__name__) @@ -86,7 +86,7 @@ def _initialize_enforcer() -> FastEnforcer: # Best to lazy load it when it's first used to ensure the database is ready and avoid # issues when the app is not fully loaded (e.g., while pulling translations, etc.). initialize_enforcer(db_alias) - except Exception as e: # pylint: disable=broad-exception-caught + except Exception as e: logger.error(f"Failed to initialize Casbin enforcer with DB alias '{db_alias}': {e}") raise diff --git a/openedx_authz/management/commands/load_policies.py b/openedx_authz/management/commands/load_policies.py index 5cf39c2e..bb00439c 100644 --- a/openedx_authz/management/commands/load_policies.py +++ b/openedx_authz/management/commands/load_policies.py @@ -16,7 +16,6 @@ from openedx_authz.engine.utils import migrate_policy_between_enforcers - class Command(BaseCommand): """Django management command to load policies into the authorization Django model. diff --git a/openedx_authz/tests/api/test_roles.py b/openedx_authz/tests/api/test_roles.py index 93429b7f..e7da8fd5 100644 --- a/openedx_authz/tests/api/test_roles.py +++ b/openedx_authz/tests/api/test_roles.py @@ -35,7 +35,6 @@ from openedx_authz.engine.utils import migrate_policy_between_enforcers - class BaseRolesTestCase(TestCase): """Base test case with helper methods for roles testing. From 64ee68de0c59c4fe8fde9af7adc3c8879dc72ee9 Mon Sep 17 00:00:00 2001 From: Maria Grimaldi Date: Tue, 14 Oct 2025 13:18:00 +0200 Subject: [PATCH 17/21] refactor: address quality issues --- openedx_authz/engine/apps.py | 12 ++++++++---- openedx_authz/engine/enforcer.py | 8 ++++++-- 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/openedx_authz/engine/apps.py b/openedx_authz/engine/apps.py index f62bdf17..eebbfffd 100644 --- a/openedx_authz/engine/apps.py +++ b/openedx_authz/engine/apps.py @@ -14,7 +14,11 @@ class CasbinAdapterConfig(AppConfig): name = "casbin_adapter" def ready(self): - """Initialize the casbin_adapter app.""" - # DO NOT initialize the enforcer here to avoid issues when - # apps are not fully loaded (e.g., while pulling translations). - # It's best to lazy load the enforcer when needed it's first used. + """Initialize the casbin_adapter app. + + The upstream casbin_adapter app tries to initialize the enforcer + when the app is loaded, which can lead to issues if the database is not + ready (e.g., while pulling translations). To avoid this, we override + the ready method and do not initialize the enforcer here. + """ + diff --git a/openedx_authz/engine/enforcer.py b/openedx_authz/engine/enforcer.py index e30b3277..76b4d12c 100644 --- a/openedx_authz/engine/enforcer.py +++ b/openedx_authz/engine/enforcer.py @@ -35,11 +35,15 @@ class AuthzEnforcer: ExtendedAdapter and Redis watcher for policy management and synchronization. There are two main use cases for this class: - 1. Directly get the enforcer instance and initialize it if needed: + + 1. Directly get the enforcer instance and initialize it if needed:: + from openedx_authz.engine.enforcer import AuthzEnforcer enforcer = AuthzEnforcer.get_enforcer() allowed = enforcer.enforce(user, resource, action) - 2. Instantiate the class to get the singleton enforcer instance: + + 2. Instantiate the class to get the singleton enforcer instance:: + from openedx_authz.engine.enforcer import AuthzEnforcer enforcer = AuthzEnforcer() allowed = enforcer.get_enforcer().enforce(user, resource, action) From bd9d677ad8f375f29ffe7391c8485e5990cac01e Mon Sep 17 00:00:00 2001 From: Maria Grimaldi Date: Tue, 14 Oct 2025 13:54:44 +0200 Subject: [PATCH 18/21] refactor: address quality issues --- openedx_authz/engine/apps.py | 1 - 1 file changed, 1 deletion(-) diff --git a/openedx_authz/engine/apps.py b/openedx_authz/engine/apps.py index eebbfffd..a35e6df8 100644 --- a/openedx_authz/engine/apps.py +++ b/openedx_authz/engine/apps.py @@ -21,4 +21,3 @@ def ready(self): ready (e.g., while pulling translations). To avoid this, we override the ready method and do not initialize the enforcer here. """ - From 370d380ee34fda88a4794358e6307ed83aead3ed Mon Sep 17 00:00:00 2001 From: Maria Grimaldi Date: Tue, 14 Oct 2025 18:07:29 +0200 Subject: [PATCH 19/21] refactor: use load policy before enforcing --- openedx_authz/api/permissions.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/openedx_authz/api/permissions.py b/openedx_authz/api/permissions.py index 46d75302..9449c446 100644 --- a/openedx_authz/api/permissions.py +++ b/openedx_authz/api/permissions.py @@ -63,6 +63,8 @@ def is_subject_allowed( Returns: bool: True if the subject has the specified permission in the scope, False otherwise. """ - return AuthzEnforcer.get_enforcer().enforce( + enforcer = AuthzEnforcer.get_enforcer() + enforcer.load_policy() + return enforcer.enforce( subject.namespaced_key, action.namespaced_key, scope.namespaced_key ) From 55a58ec0bb5ce4276595aad153c29f52a917c7c0 Mon Sep 17 00:00:00 2001 From: Maria Grimaldi Date: Thu, 16 Oct 2025 13:56:39 +0200 Subject: [PATCH 20/21] refactor: declare enforcer variable --- openedx_authz/api/permissions.py | 3 ++- openedx_authz/api/roles.py | 9 ++++++--- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/openedx_authz/api/permissions.py b/openedx_authz/api/permissions.py index 9449c446..a1cc3de0 100644 --- a/openedx_authz/api/permissions.py +++ b/openedx_authz/api/permissions.py @@ -42,7 +42,8 @@ def get_all_permissions_in_scope(scope: ScopeData) -> list[PermissionData]: Returns: list of PermissionData: A list of PermissionData objects associated with the given scope. """ - actions = AuthzEnforcer.get_enforcer().get_filtered_policy( + enforcer = AuthzEnforcer.get_enforcer() + actions = enforcer.get_filtered_policy( PolicyIndex.SCOPE.value, scope.namespaced_key ) return [get_permission_from_policy(action) for action in actions] diff --git a/openedx_authz/api/roles.py b/openedx_authz/api/roles.py index c5ce62ba..e415a747 100644 --- a/openedx_authz/api/roles.py +++ b/openedx_authz/api/roles.py @@ -59,7 +59,8 @@ def get_permissions_for_single_role( Returns: list[PermissionData]: A list of PermissionData objects associated with the given role. """ - policies = AuthzEnforcer.get_enforcer().get_implicit_permissions_for_user(role.namespaced_key) + enforcer = AuthzEnforcer.get_enforcer() + policies = enforcer.get_implicit_permissions_for_user(role.namespaced_key) return [get_permission_from_policy(policy) for policy in policies] @@ -279,8 +280,9 @@ def get_subject_role_assignments(subject: SubjectData) -> list[RoleAssignmentDat Returns: list[RoleAssignmentData]: A list of role assignments for the subject. """ + enforcer = AuthzEnforcer.get_enforcer() role_assignments = [] - for policy in AuthzEnforcer.get_enforcer().get_filtered_grouping_policy( + for policy in enforcer.get_filtered_grouping_policy( GroupingPolicyIndex.SUBJECT.value, subject.namespaced_key ): role = RoleData(namespaced_key=policy[GroupingPolicyIndex.ROLE.value]) @@ -343,8 +345,9 @@ def get_subject_role_assignments_for_role_in_scope( Returns: list[RoleAssignmentData]: A list of subjects assigned to the specified role in the specified scope. """ + enforcer = AuthzEnforcer.get_enforcer() role_assignments = [] - for subject in AuthzEnforcer.get_enforcer().get_users_for_role_in_domain( + for subject in enforcer.get_users_for_role_in_domain( role.namespaced_key, scope.namespaced_key ): if subject.startswith(f"{RoleData.NAMESPACE}{RoleData.SEPARATOR}"): From 6e15616c7f06fc314ea69b067b4efd9e286e7cb1 Mon Sep 17 00:00:00 2001 From: Maria Grimaldi Date: Thu, 16 Oct 2025 13:59:52 +0200 Subject: [PATCH 21/21] docs: update changelog and fix release order --- CHANGELOG.rst | 16 ++++++++++++---- openedx_authz/__init__.py | 2 +- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index bb4c7a14..16d5e4b7 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -16,13 +16,21 @@ Unreleased * -0.1.0 - 2025-08-27 +0.4.0 - 2025-16-10 +****************** + +Changed +======= + +* Initialize enforcer when application is ready to avoid access errors. + +0.3.0 - 2025-10-10 ****************** Added ===== -* Basic repo structure and initial setup. +* Implementation of REST API for roles and permissions management. 0.2.0 - 2025-10-10 ****************** @@ -34,10 +42,10 @@ Added * Casbin model (CONF) and engine layer for authorization. * Implementation of public API for roles and permissions management. -0.3.0 - 2025-10-10 +0.1.0 - 2025-08-27 ****************** Added ===== -* Implementation of REST API for roles and permissions management. +* Basic repo structure and initial setup. diff --git a/openedx_authz/__init__.py b/openedx_authz/__init__.py index b40bc4b9..cb5919d7 100644 --- a/openedx_authz/__init__.py +++ b/openedx_authz/__init__.py @@ -4,6 +4,6 @@ import os -__version__ = "0.3.0" +__version__ = "0.4.0" ROOT_DIRECTORY = os.path.dirname(os.path.abspath(__file__))