Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@ Change Log
Unreleased
**********

*
* Use a SyncedEnforcer with default auto load policy.


0.5.0 - 2025-10-21
******************
Expand Down
1 change: 0 additions & 1 deletion openedx_authz/api/permissions.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,6 @@ def is_subject_allowed(
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
)
7 changes: 0 additions & 7 deletions openedx_authz/api/roles.py
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,6 @@ def get_permissions_for_active_roles_in_scope(
permissions and scopes.
"""
enforcer = AuthzEnforcer.get_enforcer()
enforcer.load_policy()
filtered_policy = enforcer.get_filtered_grouping_policy(
GroupingPolicyIndex.SCOPE.value, scope.namespaced_key
)
Expand Down Expand Up @@ -149,7 +148,6 @@ def get_role_definitions_in_scope(scope: ScopeData) -> list[RoleData]:
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
)
Expand Down Expand Up @@ -196,7 +194,6 @@ def get_all_roles_in_scope(scope: ScopeData) -> list[list[str]]:
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
)
Expand All @@ -216,7 +213,6 @@ def assign_role_to_subject_in_scope(
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,
role.namespaced_key,
Expand Down Expand Up @@ -251,7 +247,6 @@ def unassign_role_from_subject_in_scope(
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
)
Expand Down Expand Up @@ -311,7 +306,6 @@ def get_subject_role_assignments_in_scope(
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 = []
for namespaced_key in enforcer.get_roles_for_user_in_domain(
Expand Down Expand Up @@ -412,6 +406,5 @@ def get_subjects_for_role(role: RoleData) -> list[SubjectData]:
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]
17 changes: 9 additions & 8 deletions openedx_authz/engine/enforcer.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@

import logging

from casbin import FastEnforcer
from casbin import SyncedEnforcer
from casbin_adapter.enforcer import initialize_enforcer
from django.conf import settings

Expand All @@ -29,7 +29,7 @@


class AuthzEnforcer:
"""Singleton class to manage the Casbin FastEnforcer instance.
"""Singleton class to manage the Casbin SyncedEnforcer instance.

Ensures a single enforcer instance is created safely and configured with the
ExtendedAdapter and Redis watcher for policy management and synchronization.
Expand Down Expand Up @@ -60,28 +60,28 @@ def __new__(cls):
return cls._enforcer

@classmethod
def get_enforcer(cls) -> FastEnforcer:
def get_enforcer(cls) -> SyncedEnforcer:
"""Get the enforcer instance, creating it if needed.

Returns:
FastEnforcer: The singleton enforcer instance.
SyncedEnforcer: The singleton enforcer instance.
"""
if cls._enforcer is None:
cls._enforcer = cls._initialize_enforcer()
return cls._enforcer

@staticmethod
def _initialize_enforcer() -> FastEnforcer:
def _initialize_enforcer() -> SyncedEnforcer:
"""
Create and configure the Casbin FastEnforcer instance.
Create and configure the Casbin SyncedEnforcer 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
SyncedEnforcer: Configured Casbin enforcer with adapter and watcher
"""
db_alias = getattr(settings, "CASBIN_DB_ALIAS", "default")

Expand All @@ -95,7 +95,8 @@ def _initialize_enforcer() -> FastEnforcer:
raise

adapter = ExtendedAdapter()
enforcer = FastEnforcer(settings.CASBIN_MODEL, adapter, enable_log=True)
enforcer = SyncedEnforcer(settings.CASBIN_MODEL, adapter)
enforcer.start_auto_load_policy(settings.CASBIN_AUTO_LOAD_POLICY_INTERVAL)
enforcer.enable_auto_save(True)

if not Watcher:
Expand Down
36 changes: 8 additions & 28 deletions openedx_authz/rest_api/v1/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,18 +76,14 @@ class PermissionValidationMeView(APIView):

**Example Request**

POST /api/authz/v1/permissions/validate/me

.. code-block:: json
POST /api/authz/v1/permissions/validate/me::

[
{"action": "edit_library", "scope": "lib:DemoX:CSPROB"},
{"action": "delete_library_content", "scope": "lib:OpenedX:CS50"}
]

**Example Response**

.. code-block:: json
**Example Response**::

[
{"action": "edit_library", "scope": "lib:DemoX:CSPROB", "allowed": true},
Expand Down Expand Up @@ -115,13 +111,7 @@ def post(self, request: HttpRequest) -> Response:
action = perm["action"]
scope = perm["scope"]
allowed = api.is_user_allowed(username, action, scope)
response_data.append(
{
"action": action,
"scope": scope,
"allowed": allowed,
}
)
response_data.append({"action": action, "scope": scope, "allowed": allowed})
except ValueError as e:
logger.error(f"Error validating permission for user {username}: {e}")
return Response(data={"message": "Invalid scope format"}, status=status.HTTP_400_BAD_REQUEST)
Expand Down Expand Up @@ -178,9 +168,7 @@ class RoleUserAPIView(APIView):

**Response Format (GET)**

Returns HTTP 200 OK with:

.. code-block:: json
Returns HTTP 200 OK with::

{
"count": 2,
Expand All @@ -204,9 +192,7 @@ class RoleUserAPIView(APIView):

**Response Format (PUT)**

Returns HTTP 207 Multi-Status with:

.. code-block:: json
Returns HTTP 207 Multi-Status with::

{
"completed": [{"user_identifier": "john_doe", "status": "role_added"}],
Expand All @@ -215,9 +201,7 @@ class RoleUserAPIView(APIView):

**Response Format (DELETE)**

Returns HTTP 207 Multi-Status with:

.. code-block:: json
Returns HTTP 207 Multi-Status with::

{
"completed": [{"user_identifier": "john_doe", "status": "role_removed"}],
Expand All @@ -233,9 +217,7 @@ class RoleUserAPIView(APIView):

GET /api/authz/v1/roles/users/?scope=lib:DemoX:CSPROB&search=john&roles=library_admin

PUT /api/authz/v1/roles/users/

.. code-block:: json
PUT /api/authz/v1/roles/users/ ::

{
"role": "library_admin",
Expand Down Expand Up @@ -404,9 +386,7 @@ class RoleListView(APIView):

GET /api/authz/v1/roles/?scope=lib:OpenedX:CSPROB&page=1&page_size=10

**Example Response**

.. code-block:: json
**Example Response**::

{
"count": 2,
Expand Down
2 changes: 2 additions & 0 deletions openedx_authz/settings/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ def plugin_settings(settings):
ROOT_DIRECTORY, "engine", "config", "model.conf"
)
settings.CASBIN_WATCHER_ENABLED = False
if not hasattr(settings, "CASBIN_AUTO_LOAD_POLICY_INTERVAL"):
settings.CASBIN_AUTO_LOAD_POLICY_INTERVAL = 5
# TODO: Replace with a more dynamic configuration
# Redis host and port are temporarily loaded here for the MVP
settings.REDIS_HOST = "redis"
Expand Down
1 change: 1 addition & 0 deletions openedx_authz/settings/test.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ def plugin_settings(settings): # pylint: disable=unused-argument

# Casbin configuration
CASBIN_MODEL = os.path.join(ROOT_DIRECTORY, "engine", "config", "model.conf")
CASBIN_AUTO_LOAD_POLICY_INTERVAL = 1
CASBIN_WATCHER_ENABLED = False
REDIS_HOST = "redis"
REDIS_PORT = 6379
32 changes: 22 additions & 10 deletions openedx_authz/tests/api/test_roles.py
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,28 @@ def setUpClass(cls):
super().setUpClass()
cls._seed_database_with_policies()

@classmethod
def tearDownClass(cls):
"""Clean up after all tests in the class.

Stops the auto-load policy thread to prevent database locking issues
with SQLite during concurrent access.
"""
super().tearDownClass()
enforcer = AuthzEnforcer.get_enforcer()
if hasattr(enforcer, 'stop_auto_load_policy'):
enforcer.stop_auto_load_policy()

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


class RolesTestSetupMixin(BaseRolesTestCase):
"""Test case with comprehensive role assignments for general roles testing."""
Expand Down Expand Up @@ -230,16 +252,6 @@ 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):
Expand Down
Loading