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__)) diff --git a/openedx_authz/api/permissions.py b/openedx_authz/api/permissions.py index 097ebb81..a1cc3de0 100644 --- a/openedx_authz/api/permissions.py +++ b/openedx_authz/api/permissions.py @@ -6,7 +6,7 @@ """ 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 __all__ = [ "get_permission_from_policy", @@ -42,7 +42,10 @@ 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) + 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] @@ -61,5 +64,8 @@ def is_subject_allowed( Returns: bool: True if the subject has the specified permission in the scope, False otherwise. """ + enforcer = AuthzEnforcer.get_enforcer() enforcer.load_policy() - return enforcer.enforce(subject.namespaced_key, action.namespaced_key, scope.namespaced_key) + return 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 c1db6c9f..e415a747 100644 --- a/openedx_authz/api/roles.py +++ b/openedx_authz/api/roles.py @@ -20,7 +20,7 @@ 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 __all__ = [ "get_permissions_for_single_role", @@ -59,6 +59,7 @@ def get_permissions_for_single_role( Returns: list[PermissionData]: A list of PermissionData objects associated with the given role. """ + enforcer = AuthzEnforcer.get_enforcer() policies = enforcer.get_implicit_permissions_for_user(role.namespaced_key) return [get_permission_from_policy(policy) for policy in policies] @@ -114,6 +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 = AuthzEnforcer.get_enforcer() enforcer.load_policy() filtered_policy = enforcer.get_filtered_grouping_policy( GroupingPolicyIndex.SCOPE.value, scope.namespaced_key @@ -146,6 +148,7 @@ def get_role_definitions_in_scope(scope: ScopeData) -> list[RoleData]: Returns: list[Role]: A list of roles. """ + enforcer = AuthzEnforcer.get_enforcer() enforcer.load_policy() policy_filtered = enforcer.get_filtered_policy( PolicyIndex.SCOPE.value, scope.namespaced_key @@ -180,7 +183,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]]: @@ -192,6 +195,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 = AuthzEnforcer.get_enforcer() enforcer.load_policy() return enforcer.get_filtered_grouping_policy( GroupingPolicyIndex.SCOPE.value, scope.namespaced_key @@ -211,6 +215,7 @@ def assign_role_to_subject_in_scope( Returns: bool: True if the role was assigned successfully, False otherwise. """ + enforcer = AuthzEnforcer.get_enforcer() enforcer.load_policy() return enforcer.add_role_for_user_in_domain( subject.namespaced_key, @@ -245,6 +250,7 @@ def unassign_role_from_subject_in_scope( Returns: bool: True if the role was unassigned successfully, False otherwise. """ + enforcer = AuthzEnforcer.get_enforcer() enforcer.load_policy() return enforcer.delete_roles_for_user_in_domain( subject.namespaced_key, role.namespaced_key, scope.namespaced_key @@ -274,6 +280,7 @@ 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 enforcer.get_filtered_grouping_policy( GroupingPolicyIndex.SUBJECT.value, subject.namespaced_key @@ -303,6 +310,7 @@ def get_subject_role_assignments_in_scope( Returns: list[RoleAssignmentData]: A list of role assignments for the subject in the scope. """ + enforcer = AuthzEnforcer.get_enforcer() enforcer.load_policy() # TODO: we still need to get the remaining data for the role like email, etc role_assignments = [] @@ -337,6 +345,7 @@ 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 enforcer.get_users_for_role_in_domain( role.namespaced_key, scope.namespaced_key @@ -402,6 +411,7 @@ def get_subjects_for_role(role: RoleData) -> list[SubjectData]: Returns: list[SubjectData]: A list of subjects assigned to the specified role. """ + 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/engine/apps.py b/openedx_authz/engine/apps.py new file mode 100644 index 00000000..a35e6df8 --- /dev/null +++ b/openedx_authz/engine/apps.py @@ -0,0 +1,23 @@ +"""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 + + +class CasbinAdapterConfig(AppConfig): + name = "casbin_adapter" + + def ready(self): + """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 f9c8a335..76b4d12c 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. @@ -19,6 +19,7 @@ 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 @@ -26,13 +27,85 @@ 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. + + 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 + + def __new__(cls): + """Singleton pattern to ensure a single enforcer instance.""" + 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 + + @staticmethod + def _initialize_enforcer() -> FastEnforcer: + """ + Create and configure the Casbin FastEnforcer instance. + + 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") + + 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: + 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 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 diff --git a/openedx_authz/management/commands/load_policies.py b/openedx_authz/management/commands/load_policies.py index 36d34ad6..bb00439c 100644 --- a/openedx_authz/management/commands/load_policies.py +++ b/openedx_authz/management/commands/load_policies.py @@ -12,7 +12,7 @@ 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 @@ -74,7 +74,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/settings/common.py b/openedx_authz/settings/common.py index 22feabd3..98620ca7 100644 --- a/openedx_authz/settings/common.py +++ b/openedx_authz/settings/common.py @@ -17,13 +17,14 @@ 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 - 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" 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 = [ diff --git a/openedx_authz/tests/api/test_roles.py b/openedx_authz/tests/api/test_roles.py index 6a8df637..e7da8fd5 100644 --- a/openedx_authz/tests/api/test_roles.py +++ b/openedx_authz/tests/api/test_roles.py @@ -31,7 +31,7 @@ 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 @@ -50,6 +50,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( @@ -96,16 +97,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.""" @@ -241,6 +232,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 3d3b033c..83cb1f14 100644 --- a/openedx_authz/tests/test_enforcer.py +++ b/openedx_authz/tests/test_enforcer.py @@ -10,7 +10,7 @@ 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 @@ -63,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( @@ -85,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: @@ -97,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: @@ -114,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: @@ -129,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"], @@ -170,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( @@ -189,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()) @@ -219,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) @@ -239,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) @@ -261,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) @@ -291,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() @@ -318,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() @@ -335,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()) @@ -358,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) @@ -375,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^*"