Skip to content
Merged
Show file tree
Hide file tree
Changes from 18 commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
ed9ef8f
refactor: manage enforcer state within class when app is ready
mariajgrimaldi Oct 10, 2025
19bf8df
refactor: get class attribute in runtime so it's available
mariajgrimaldi Oct 10, 2025
3c728c4
refactor: use common settings module as default
mariajgrimaldi Oct 10, 2025
986d014
refactor: move adding installed app when apps are ready
mariajgrimaldi Oct 13, 2025
421cd21
refactor: move authzenforcer to ready hook
mariajgrimaldi Oct 13, 2025
015b341
refactor: add casbin adapter to common to avoid build failing
mariajgrimaldi Oct 13, 2025
84f4cee
refactor: include casbin in installed apps before loading models
mariajgrimaldi Oct 13, 2025
71503ea
Revert "refactor: add casbin adapter to common to avoid build failing"
mariajgrimaldi Oct 13, 2025
0af187b
refactor: move installed apps configuration to common settings
mariajgrimaldi Oct 13, 2025
cb06ea2
refactor: skip improperly configured issue when apps are not fully co…
mariajgrimaldi Oct 13, 2025
820c8d4
refactor: override casbin adapter config for a safe load
mariajgrimaldi Oct 13, 2025
4a77050
refactor: go back to using test settings but fix import order
mariajgrimaldi Oct 13, 2025
9ceaa07
refactor: initialize enforcer when firstly use to avoid raising error…
mariajgrimaldi Oct 13, 2025
336d7d7
docs: add inline doc explaining why initialize enforcer
mariajgrimaldi Oct 13, 2025
b2bb892
refactor: address rebase failure
mariajgrimaldi Oct 14, 2025
5996ef9
refactor: address quality issues
mariajgrimaldi Oct 14, 2025
64ee68d
refactor: address quality issues
mariajgrimaldi Oct 14, 2025
bd9d677
refactor: address quality issues
mariajgrimaldi Oct 14, 2025
370d380
refactor: use load policy before enforcing
mariajgrimaldi Oct 14, 2025
55a58ec
refactor: declare enforcer variable
mariajgrimaldi Oct 16, 2025
6e15616
docs: update changelog and fix release order
mariajgrimaldi Oct 16, 2025
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
11 changes: 7 additions & 4 deletions openedx_authz/api/permissions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -42,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]


Expand All @@ -61,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(
Comment thread
mariajgrimaldi marked this conversation as resolved.
Outdated
subject.namespaced_key, action.namespaced_key, scope.namespaced_key
)
17 changes: 12 additions & 5 deletions openedx_authz/api/roles.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -59,7 +59,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]


Expand Down Expand Up @@ -114,6 +114,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
Expand Down Expand Up @@ -146,6 +147,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
Expand Down Expand Up @@ -180,7 +182,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]]:
Expand All @@ -192,6 +194,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
Expand All @@ -211,6 +214,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,
Expand Down Expand Up @@ -245,6 +249,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
Expand Down Expand Up @@ -275,7 +280,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])
Expand Down Expand Up @@ -303,6 +308,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 = []
Expand Down Expand Up @@ -338,7 +344,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}"):
Expand Down Expand Up @@ -402,6 +408,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]
23 changes: 23 additions & 0 deletions openedx_authz/engine/apps.py
Original file line number Diff line number Diff line change
@@ -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.
"""
93 changes: 83 additions & 10 deletions openedx_authz/engine/enforcer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -19,20 +19,93 @@
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

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()
Comment on lines +69 to +70
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think this would be needed.

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
4 changes: 2 additions & 2 deletions openedx_authz/management/commands/load_policies.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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.
Expand Down
9 changes: 5 additions & 4 deletions openedx_authz/settings/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion openedx_authz/settings/test.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand Down
23 changes: 12 additions & 11 deletions openedx_authz/tests/api/test_roles.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand All @@ -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(
Expand Down Expand Up @@ -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."""
Expand Down Expand Up @@ -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):
Expand Down
Loading