Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
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
2 changes: 2 additions & 0 deletions .annotation_safe_list.yml
Original file line number Diff line number Diff line change
Expand Up @@ -39,3 +39,5 @@ waffle.Sample:
".. no_pii:": "This model has no PII"
waffle.Switch:
".. no_pii:": "This model has no PII"
casbin_adapter.CasbinRule:
".. no_pii:": "This model stores authorization policy rules and contains no PII"
2 changes: 1 addition & 1 deletion .coveragerc
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,9 @@ branch = True
data_file = .coverage
source=openedx_authz
omit =
test_settings.py
*/migrations/*
*admin.py
*/static/*
*/templates/*
*/tests/*
*/settings/*
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -63,3 +63,6 @@ docs/openedx_authz.*.rst
# Private requirements
requirements/private.in
requirements/private.txt

# Sqlite Database
*.db
4 changes: 2 additions & 2 deletions docs/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ def get_version(*file_paths):

VERSION = get_version("../openedx_authz", "__init__.py")
# Configure Django for autodoc usage
os.environ["DJANGO_SETTINGS_MODULE"] = "test_settings"
os.environ["DJANGO_SETTINGS_MODULE"] = "openedx_authz.settings.test"
django_setup()

# If extensions (or modules to document with autodoc) are in another directory,
Expand Down Expand Up @@ -407,7 +407,7 @@ def get_version(*file_paths):
documentation_title,
author,
project_title,
"One-line description for README and other doc files.",
"Open edX AuthZ provides the architecture and foundations of the authorization framework.",
"Miscellaneous",
),
]
Expand Down
2 changes: 1 addition & 1 deletion docs/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
openedx-authz
=============

One-line description for README and other doc files.
Open edX AuthZ provides the architecture and foundations of the authorization framework.

Contents:

Expand Down
2 changes: 1 addition & 1 deletion manage.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
PWD = os.path.abspath(os.path.dirname(__file__))

if __name__ == "__main__":
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "test_settings")
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "openedx_authz.settings.test")
sys.path.append(PWD)
try:
from django.core.management import execute_from_command_line
Expand Down
29 changes: 28 additions & 1 deletion openedx_authz/apps.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,4 +11,31 @@ class OpenedxAuthzConfig(AppConfig):
"""

name = "openedx_authz"
plugin_app = {}
verbose_name = "Open edX AuthZ"
default_auto_field = "django.db.models.BigAutoField"
plugin_app = {
"url_config": {
"lms.djangoapp": {
"namespace": "openedx-authz",
"regex": r"^openedx-authz/",
"relative_path": "urls",
},
"cms.djangoapp": {
"namespace": "openedx-authz",
"regex": r"^openedx-authz/",
"relative_path": "urls",
},
},
"settings_config": {
"lms.djangoapp": {
"test": {"relative_path": "settings.test"},
"common": {"relative_path": "settings.common"},
"production": {"relative_path": "settings.production"},
},
"cms.djangoapp": {
"test": {"relative_path": "settings.test"},
"common": {"relative_path": "settings.common"},
"production": {"relative_path": "settings.production"},
},
},
}
Empty file.
123 changes: 123 additions & 0 deletions openedx_authz/engine/adapter.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
"""
Extended Casbin Adapter with Filtering Support.

This module provides an enhanced adapter implementation for Casbin that extends
the base Django adapter with filtering capabilities. The ExtendedAdapter allows
for efficient loading of policy rules from the database with support for
filtering based on policy attributes.

The adapter combines functionality from both the base Adapter (for Django ORM
integration) and FilteredAdapter (for selective policy loading) to provide
optimized policy management for authorization systems.
"""

from enum import Enum

from casbin import persist
from casbin.model import Model
from casbin.persist import FilteredAdapter
from casbin_adapter.adapter import Adapter
from casbin_adapter.models import CasbinRule
from django.db.models import QuerySet

from openedx_authz.engine.filter import Filter


class PolicyAttribute(Enum):
"""
Enumeration of Casbin policy attributes.

These attributes map to the columns of the CasbinRule table, but their meaning
depends on the policy type (ptype). Check the ``openedx_authz.engine.Filter`` class
for more details.
"""

PTYPE = "ptype"
"""ptype (str): Type of policy"""

V0 = "v0"
"""v0 (str): First policy value."""

V1 = "v1"
"""v1 (str): Second policy value."""

V2 = "v2"
"""v2 (str): Third policy value."""

V3 = "v3"
"""v3 (str): Fourth policy value."""

V4 = "v4"
"""v4 (str): Fifth policy value."""

V5 = "v5"
"""v5 (str): Sixth policy value."""


class ExtendedAdapter(Adapter, FilteredAdapter):
"""
Extended Casbin adapter with filtering capabilities.

This adapter extends the base Django ORM Casbin adapter to support filtered
policy loading, allowing for more efficient policy management by loading
only relevant policy rules based on specified filter criteria.

Inherits from:
Adapter: Base Django adapter for Casbin policy persistence.
FilteredAdapter: Interface for filtered policy loading.
"""

def is_filtered(self) -> bool:
"""
Check if the adapter supports filtering.

Returns:
bool: True if the adapter supports filtered policy loading, False otherwise.
"""
return True

def load_filtered_policy(self, model: Model, filter: Filter) -> None: # pylint: disable=redefined-builtin
Copy link
Member

@mariajgrimaldi mariajgrimaldi Sep 22, 2025

Choose a reason for hiding this comment

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

[critical] I'm thinking about where we should call this and how often, and I have a few ideas. Let me know what you think:

  1. We originally said we shouldn't hit the database on every enforce, but this might still be acceptable for the MVP.
  2. We could call this from middleware or at the view level, where the filter is: the requested org, user, object, or SAOC request. This depends on what's available. I think this could cover all enforcement queries, including explicit calls to our views or those using the has_permission helper we're adding in api/*.

These questions lead me to another point about cache management and its relation to the watcher. What does loading a subset of policies mean in this context? To me, it means loading them into Redis as part of cache management, with the watcher invalidating entries when the loaded policy (filtered or not) changes. Am I understanding this correctly?

Here's a diagram of what I think this should work: https://openedx.atlassian.net/wiki/spaces/OEPM/pages/5210112002/Open+edX+AuthZ+Framework+Long-Term+Vision#Policy-Loading-Strategy

Copy link
Member

Choose a reason for hiding this comment

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

I'm working on the public API and facing the same question at a lower level: where should the filtered policy be loaded? Right now, I'm treating all calls as scoped. That means I could call load_filtered_policy each time a function runs with the given scope, then work with the resulting rules. For testing, I'm not yet loading filtered policies.

In any case, looking at the bigger picture, I think this approach might work:

  • Receive a request with a scope, defaulting to global if none is provided
  • Load the filtered policy into the adapter (still not sure if cache should be managed here, as mentioned before)
  • If the policy changes, clear the enforcer and reload it (not sure "clear" is the best term here)
  • If a new request comes in with a different scope, load that new scope

Do you think this makes sense?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

@mariajgrimaldi, For the MVP, I agree it might be acceptable to hit the database more frequently. Also, I agree with your last approach, we can call load_filtered_policy at the view level (or middleware), based on the request scope (org, course, user, etc.). This way, we keep the logic centralized and consistent.

I’d like to expand on two points you brought up:

  1. The watcher only notifies enforcers when a policy change happens, but the actual synchronization should happen in the callback. Right now, we only have logging there, but in most setups, you’d see something like enforcer.load_policy(). The issue is that we don’t want to reload the entire policy into memory every time, since that could be too heavy.
  2. About the FastEnforcer cache, I’m still digging into how the caching mechanism works. At the moment, since we haven’t configured any cache (i.e., nothing is passed in the cache_key_order argument), it’s falling back to the standard enforcer of Casbin.

Copy link
Member

Choose a reason for hiding this comment

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

Opened issues to address this:
#73
#72

"""
Load policy rules from storage with filtering applied.

This method loads policy rules from the database and applies the specified
filter to load only relevant rules. The filtered rules are then loaded
into the provided Casbin model.

IMPORTANT: This method is used internally by the ``enforcer.load_filtered_policy()``
method. Do not call this method directly. If you need to load policy rules, use
the ``enforcer.load_filtered_policy()`` method.

Args:
model (Model): The Casbin model to load policy rules into.
filter (Filter): Filter object containing criteria for policy selection.
Should have attributes like ptype, v0, v1, etc. with lists
of values to filter by.
"""
queryset = CasbinRule.objects.using(self.db_alias)
Copy link
Member

@mariajgrimaldi mariajgrimaldi Sep 19, 2025

Choose a reason for hiding this comment

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

[critical] Do we have to do this each time we load a filtered policy? Can we do it once during initialization?

filtered_queryset = self.filter_query(queryset, filter)
for line in filtered_queryset:
persist.load_policy_line(str(line), model)
Copy link
Member

@mariajgrimaldi mariajgrimaldi Sep 22, 2025

Choose a reason for hiding this comment

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

[critical] Why is this line needed? Considering that we already have a filtered queryset to work on, do we need to evaluate it here? I'm guessing there's something I'm missing about casbin internals here. Let me know!

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yes, because we need the policy to be loaded in a format that Casbin can work with. The load_filtered_policy method doesn’t return anything. It just performs a filtered query and, based on that query, persists each line into the policy list.

It’s done the same way in the SQLAlchemy adapter

Copy link
Member

@mariajgrimaldi mariajgrimaldi Sep 25, 2025

Choose a reason for hiding this comment

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

Right. This will cause a problem when trying to associate additional data to the Casbin rule, like metadata, because we will lose the relationship between this model's records and any other when loading the line. If we keep it as is, that is. Additionally, depending on the level of granularity in the filter, it may also be considered a performance concern.

Do you have any suggestions on how we can address this issue? I'll try to think about something.

⚠️ This is not a problem or a blocker for this particular PR but it is for our approach for adding additional metadata to casbin rules.

Copy link
Member

@mariajgrimaldi mariajgrimaldi Sep 25, 2025

Choose a reason for hiding this comment

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

Maybe we could link the rule itself to our extended policy model so the lookup could be O(1) since each rule is unique, we can go over it when we open the PR for the model.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Just to clarify. The load_filtered_policy doesn’t modify anything in the database, so the IDs of each rule in the Casbin table will remain the same. What it does during the load is keep the policies in memory based on the filter, so they can later be used in other Casbin methods like get_roles_for_user, get_users_for_role, etc.

The problem might lie in how we’re going to access the metadata, since we wouldn’t know the ID of each loaded policy.

What you’re suggesting could be a solution, we could give it a try.

Copy link
Member

@mariajgrimaldi mariajgrimaldi Sep 26, 2025

Choose a reason for hiding this comment

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

@BryanttV: exatcly, our main problem would be losing the linking between the two models.

@MaferMazu: here's some of the ideas we've discussed today about storing metadata as an extended policy model.


def filter_query(self, queryset: QuerySet, filter: Filter) -> QuerySet: # pylint: disable=redefined-builtin
"""
Apply filter criteria to the policy queryset.

This method takes a Django queryset of CasbinRule objects and applies
filtering based on the provided filter object's attributes. It supports
filtering by policy type (ptype) and policy values (v0-v5).

Args:
queryset (QuerySet): Django queryset of CasbinRule objects to filter.
filter (Filter): Filter object with attributes (ptype, v0, v1, v2, v3, v4, v5)
containing lists of values to filter by. Empty lists are ignored.

Returns:
QuerySet: Filtered and ordered queryset of CasbinRule objects.
"""
for attr in PolicyAttribute:
filter_values = getattr(filter, attr.value)
if len(filter_values) > 0:
filter_kwargs = {f"{attr.value}__in": filter_values}
queryset = queryset.filter(**filter_kwargs)
return queryset.order_by("id")
38 changes: 38 additions & 0 deletions openedx_authz/engine/enforcer.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
"""
Core authorization enforcer for Open edX AuthZ system.

Provides a Casbin FastEnforcer instance with extended adapter for database policy
storage and Redis watcher for distributed policy synchronization.

Components:
- Enforcer: Main FastEnforcer instance for policy evaluation
- Adapter: ExtendedAdapter for filtered database policy loading
- Watcher: Redis-based watcher for real-time policy updates

Usage:
from openedx_authz.engine.enforcer import enforcer
allowed = enforcer.enforce(user, resource, action)

Requires `CASBIN_MODEL` setting and Redis configuration for watcher functionality.
"""

import logging

from casbin import FastEnforcer
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}")
80 changes: 80 additions & 0 deletions openedx_authz/engine/filter.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
"""
Filter Implementation for Casbin Policy Selection.

This module provides a Filter class used to specify criteria for selective
loading of Casbin policy rules. The Filter class allows for efficient policy
management by enabling the loading of only relevant policy rules based on
policy type and attribute values.

The Filter class is designed to work with the ExtendedAdapter to provide
optimized policy loading in scenarios where only a subset of policies
is needed, such as loading policies for a specific user, course, or role.
"""

from typing import Optional

import attr


@attr.define
class Filter:
"""
Filter class for selective Casbin policy loading.

This class defines filtering criteria used to load only specific policy rules
from the database instead of loading all policies. Each attribute corresponds
to a column in the Casbin policy storage schema and accepts a list of values
to filter by.

Note:
- Empty lists for any attribute means no filtering on that attribute
- Non-empty lists create an "IN" filter for that attribute
- All non-empty filters are combined with AND logic
"""

ptype: Optional[list[str]] = attr.field(factory=list)
"""ptype (Optional[list[str]]): Policy type filter.

- ``p`` → Policy rule (permissions).
- ``g`` → Grouping rule (user ↔ role).
- ``g2`` → Action grouping (parent action ↔ child action).
"""

v0: Optional[list[str]] = attr.field(factory=list)
"""v0 (Optional[list[str]]): First policy value filter.

- For ``p`` → Subject (e.g., ``role:org_admin``, ``user:alice``).
- For ``g`` → User (e.g., ``user:alice``).
- For ``g2`` → Parent action (e.g., ``act:manage``).
"""

v1: Optional[list[str]] = attr.field(factory=list)
"""v1 (Optional[list[str]]): Second policy value filter.

- For ``p`` → Action (e.g., ``act:manage``, ``act:edit``).
- For ``g`` → Role (e.g., ``role:org_admin``).
- For ``g2`` → Child action (e.g., ``act:edit``).
"""

v2: Optional[list[str]] = attr.field(factory=list)
"""v2 (Optional[list[str]]): Third policy value filter.

- For ``p`` → Object or resource (e.g., ``lib:*``, ``org:MIT``).
- For ``g`` → Scope or resource (e.g., ``org:MIT``).
- For ``g2`` → Not used.
"""

v3: Optional[list[str]] = attr.field(factory=list)
"""v3 (Optional[list[str]]): Fourth policy value filter.

- For ``p`` → Effect (allow or deny).
- Otherwise unused.
"""

v4: Optional[list[str]] = attr.field(factory=list)
"""v4 (Optional[list[str]]): Fifth policy value filter (optional additional context).
"""

v5: Optional[list[str]] = attr.field(factory=list)
"""v5 (Optional[list[str]]): Sixth policy value filter (optional additional context).
"""
59 changes: 59 additions & 0 deletions openedx_authz/engine/watcher.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
"""
Redis-based policy change watcher for the authorization enforcer.

This module provides functionality to monitor policy changes in real-time using Redis
as a message broker. It enables automatic policy reloading across multiple instances
of the authorization system to maintain consistency and synchronization.

The watcher connects to Redis on the configured host and port, listens for policy
change events, and automatically triggers policy reloads when changes are detected.
This ensures that all running instances of the authorization system stay synchronized
with the latest policy configurations.
"""

import logging

from django.conf import settings
from redis_watcher import WatcherOptions, new_watcher

logger = logging.getLogger(__name__)


def callback_function(event) -> None:
"""
Enhanced callback function for the enforcer that reloads policies on changes.

This function is called whenever a policy change event is received through Redis.
It reloads the policies in the enforcer to ensure all instances stay synchronized.

Args:
event: The policy change event from Redis
"""
logger.info(f"Policy change event received: {event}")
Copy link
Member

@mariajgrimaldi mariajgrimaldi Sep 22, 2025

Choose a reason for hiding this comment

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

[critical] So if we use cache for the policies as I mentioned here, we could invalidate them here?



def create_watcher():
"""
Create and configure the Redis watcher for policy changes.

Returns:
The configured watcher instance
"""
watcher_options = WatcherOptions()
watcher_options.host = settings.REDIS_HOST
Copy link
Contributor

Choose a reason for hiding this comment

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

We've recently run into cases where people are setting passwords and/or usersnames on redis as well, so we may need a more general set of connection variables

watcher_options.port = settings.REDIS_PORT
watcher_options.optional_update_callback = callback_function

try:
watcher = new_watcher(watcher_options)
logger.info("Redis watcher created successfully")
return watcher
except Exception as e:
logger.error(f"Failed to create Redis watcher: {e}")
raise


if settings.CASBIN_WATCHER_ENABLED:
Watcher = create_watcher()
else:
Watcher = None
Empty file.
Loading