Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
d3c3487
chore: add casbin in base requirements
BryanttV Sep 2, 2025
4ba8fa9
chore: add casbin and drf in base requirements
BryanttV Sep 3, 2025
a2feec6
feat: add settings
BryanttV Sep 3, 2025
97ef2a7
feat: add entry points
BryanttV Sep 3, 2025
a64f254
feat: add basic dummy api
BryanttV Sep 3, 2025
24b5da4
feat: add root_directory
BryanttV Sep 3, 2025
a7e5e48
feat: add plugin_app config
BryanttV Sep 3, 2025
6659457
feat: add model.conf for casbin
BryanttV Sep 3, 2025
212d6ea
chore: use the correct test file
BryanttV Sep 4, 2025
0bb8fcb
feat: remove keyMatch2 in the matcher
BryanttV Sep 4, 2025
be6add0
chore: add casbin middleware
BryanttV Sep 4, 2025
e484a05
chore: remove extra fields of library model
BryanttV Sep 4, 2025
26fdd1c
refactor: remove validators and extra field from the serializer
BryanttV Sep 4, 2025
a735270
feat: create initial migration
BryanttV Sep 4, 2025
1d9e857
feat: add library model to admin
BryanttV Sep 4, 2025
9743a3a
feat: use library model in views
BryanttV Sep 4, 2025
f2d57ec
feat: allow all routes to the admin
BryanttV Sep 4, 2025
127d932
feat: add custom save method
BryanttV Sep 4, 2025
868bff4
feat: set id as read only field
BryanttV Sep 4, 2025
62b30ca
refactor: simplify the methods
BryanttV Sep 4, 2025
7bffc13
feat: add minimum policies to admin and anonymous users
BryanttV Sep 4, 2025
851a940
feat: add AdminRoleAssignmentViewSet for managing admin role assignments
BryanttV Sep 4, 2025
c5b97a0
feat: add UserPermissionViewSet for managing user-specific permissions
BryanttV Sep 4, 2025
0dce5ad
refactor: use auto save in the enforcer
BryanttV Sep 5, 2025
0c2fbf3
feat: add PolicyBulkViewSet and PolicySingleViewSet for managing bulk…
BryanttV Sep 8, 2025
907027b
chore: add redis-watcher in base requirements
BryanttV Sep 8, 2025
e9165ed
refactor: replace enforcer import with custom enforcer
BryanttV Sep 9, 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
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
4 changes: 4 additions & 0 deletions openedx_authz/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,8 @@
One-line description for README and other doc files.
"""

import os

__version__ = "0.1.0"

ROOT_DIRECTORY = os.path.dirname(os.path.abspath(__file__))
8 changes: 8 additions & 0 deletions openedx_authz/admin.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
"""
Admin for openedx_authz.
"""

from django.contrib import admin
from .models import Library

admin.site.register(Library)
67 changes: 67 additions & 0 deletions openedx_authz/apps.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,70 @@ class OpenedxAuthzConfig(AppConfig):
"""

name = "openedx_authz"
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"},
},
},
}

def ready(self):
"""
Add admin users to the authorization policy.
"""
# pylint: disable=import-outside-toplevel
from django.contrib.auth import get_user_model

from openedx_authz.custom_enforcer import get_enforcer

enforcer = get_enforcer()

# Add minimum policies for anonymous users
anonymous_policies = [
("/", "*"),
("/login", "*"),
("/api/mfe_config/v1", "*"),
("/login_refresh", "*"),
("/csrf/api/v1/token", "*"),
("/api/user/v2/account/login_session/", "*"),
("/dashboard", "*"),
("/__debug__/history_sidebar/", "*"),
("/theming/asset/images/no_course_image.png", "*"),
]

for resource, action in anonymous_policies:
if not enforcer.has_policy("anonymous", resource, action):
enforcer.add_policy("anonymous", resource, action)

# Ensure admin users have access to all resources
User = get_user_model()

enforcer.add_policy("admin", "*", "*")
admin_users = User.objects.filter(is_staff=True, is_superuser=True)
for user in admin_users:
enforcer.add_role_for_user(user.username, "admin")

print("\n\nAdded default policies!\n\n")
27 changes: 27 additions & 0 deletions openedx_authz/custom_enforcer.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
"""
Enforcer instance for openedx_authz.
"""

from dauthz.core import enforcer
from redis_watcher import WatcherOptions, new_watcher


def callback_function(event):
"""
Callback function for the enforcer.
"""
print("\n\nUpdate for remove filtered policy callback, event: {}".format(event))


def get_enforcer():
Copy link
Member

Choose a reason for hiding this comment

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

I have a few comments about this approach:

  1. Can we create a package called engine to move these kinds of definitions there? We could use it for all definitions related to our authorization engine.
  2. Can we implement a class instead, called Enforcer (or another most distinguished name) that behaves like this enforcer but sets up the watcher as part of its initialization (ducktyping)? We could also implement more customizations if needed.
  3. Do we have to initialize the watcher manually here everytime we get an enforcer? Can't we use CASBIN_WATCHER setting instead or is it configured per enforcer-instance?
  4. Do we need an enforcer per django process?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

  1. Yes, I think that’s a good idea. Centralizing all those definitions we’ll need later seems like the most appropriate approach.
  2. Yes, I agree. We should have a class that wraps the original Enforcer. That way we could initialize the Watcher, Adapter, or anything else, or add any custom methods we need, such as load_filtered_policy.
  3. I wasn’t aware of the CASBIN_WATCHER setting. It could be useful to avoid the issue you mentioned. I’ll be running some tests with that configuration.
  4. Casbin doesn’t share state across processes, so a watcher would be needed to synchronize changes. Is that what you’re referring to?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I wasn’t aware of the CASBIN_WATCHER setting. It could be useful to avoid the issue you mentioned. I’ll be running some tests with that configuration

See #37 (comment)

"""
Get the enforcer instance.
"""
enforcer.enable_auto_save(True)
watcher_options = WatcherOptions()
watcher_options.host = "redis"
watcher_options.port = 6379
watcher_options.optional_update_callback = callback_function
watcher = new_watcher(watcher_options)
enforcer.set_watcher(watcher)
return enforcer
32 changes: 32 additions & 0 deletions openedx_authz/migrations/0001_initial.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# Generated by Django 4.2.23 on 2025-09-04 00:25

from django.db import migrations, models


class Migration(migrations.Migration):
initial = True

dependencies = []

operations = [
migrations.CreateModel(
name="Library",
fields=[
(
"id",
models.CharField(
help_text="Library ID in format lib:ORG:SLUG", max_length=255, primary_key=True, serialize=False
),
),
("org", models.CharField(help_text="Organization name", max_length=255)),
("slug", models.CharField(help_text="Library slug/identifier", max_length=255)),
("title", models.CharField(help_text="Library title", max_length=255)),
("description", models.TextField(blank=True, help_text="Library description")),
],
options={
"verbose_name": "Library",
"verbose_name_plural": "Libraries",
"ordering": ["title"],
},
),
]
Empty file.
14 changes: 14 additions & 0 deletions openedx_authz/model.conf
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
[request_definition]
r = sub, obj, act

[policy_definition]
p = sub, obj, act

[role_definition]
g = _, _

[policy_effect]
e = some(where (p.eft == allow))

[matchers]
m = g(r.sub, p.sub) && (p.obj == '*' || r.obj == p.obj) && (p.act == '*' || regexMatch(r.act, p.act))
29 changes: 29 additions & 0 deletions openedx_authz/models.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,32 @@
"""
Database models for openedx_authz.
"""

from django.db import models


class Library(models.Model):
"""
Model representing an OpenedX Library with basic information.
"""

id = models.CharField(max_length=255, primary_key=True, help_text="Library ID in format lib:ORG:SLUG")
org = models.CharField(max_length=255, help_text="Organization name")
slug = models.CharField(max_length=255, help_text="Library slug/identifier")
title = models.CharField(max_length=255, help_text="Library title")
description = models.TextField(blank=True, help_text="Library description")

def save(self, *args, **kwargs):
"""
Override save method to automatically generate ID from org and slug.
"""
self.id = f"lib:{self.org}:{self.slug}".replace(" ", "_")
super().save(*args, **kwargs)

class Meta:
verbose_name = "Library"
verbose_name_plural = "Libraries"
ordering = ["title"]

def __str__(self):
return str(self.title)
23 changes: 23 additions & 0 deletions openedx_authz/serializers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
"""
Serializers for openedx_authz DRF API.
"""

from rest_framework import serializers
from .models import Library


class LibrarySerializer(serializers.ModelSerializer):
"""
Serializer for OpenedX Library model.
"""

class Meta:
model = Library
fields = [
"id",
"org",
"slug",
"title",
"description",
]
read_only_fields = ["id"]
3 changes: 3 additions & 0 deletions openedx_authz/settings/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
"""
Settings package for openedx_authz plugin.
"""
49 changes: 49 additions & 0 deletions openedx_authz/settings/common.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
"""
Common settings for openedx_authz plugin.
"""

import os

from openedx_authz import ROOT_DIRECTORY


def plugin_settings(settings):
"""
Configure plugin settings for Open edX.

This function is called by the Open edX plugin system to configure
the Django settings for this plugin.

Args:
settings: The Django settings object
"""
# Add external third-party apps to INSTALLED_APPS
external_apps = [
"casbin_adapter.apps.CasbinAdapterConfig", # Casbin Adapter
"dauthz.apps.DauthzConfig", # Django Authorization library
]
for app in external_apps:
if app not in settings.INSTALLED_APPS:
settings.INSTALLED_APPS.append(app)

# Add middleware for authorization
middleware_class = "dauthz.middlewares.request_middleware.RequestMiddleware"
Copy link
Member

@mariajgrimaldi mariajgrimaldi Sep 10, 2025

Choose a reason for hiding this comment

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

This request middleware loads the entire policy into memory using enforcer.load_policy: https://github.com/pycasbin/django-authorization/blob/83a70ddcc2cd53c152b6de5f31af974c658918f9/dauthz/middlewares/request_middleware.py#L7-L15 which could severely impact performance in real production settings. I don't think using this middleware as is is viable for our use case.

This also takes me to this question here: https://openedx.atlassian.net/wiki/spaces/OEPM/pages/5210112002/WIP+Open+edX+AuthZ+Framework+Long-Term+Vision?focusedCommentId=5220433941. Django ORM adapter which is used by this library doesn't support loading filtered policies, which again is not a viable option for us loading an entire database into memory. What I think we could do is implementing our own extended adapter which filters policies, using as guide what's already implemented in the adapter itself and also in other python adapters which do support filtering policies like https://github.com/officialpycasbin/sqlalchemy-adapter

We could do something like (pseudocode, haven't tested it):

from casbin_adapter.adapter import Adapter
from casbin_adapter.models import CasbinRule

class ExtendedAdapter(Adapter):
    def __init__(self):
        super().__init__()

    def load_filtered_policy(self, filter):
        """Load only policy rules that match the filter.

        This filter should come from a more human-readable query format, e.g.:
        {
            "ptype": "p",
            "rule": ["alice", "data1", "read"]
        }
        """
        query_params = {"ptype": filter.get("ptype")}
        for i, v in enumerate(filter.get("rule", [])):
            query_params["v{}".format(i)] = v
        return CasbinRule.objects.using(self.db_alias).filter(**query_params).all()

Copy link
Member

@mariajgrimaldi mariajgrimaldi Sep 10, 2025

Choose a reason for hiding this comment

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

I tested this in the django shell, it seems to work:

In [3]: enforcer.adapter
Out[3]: <openedx_authz.engine.adapter.ExtendedAdapter at 0x71ca1e7e18d0>

In [4]: enforcer.adapter.load_filtered_policy({"ptype": "p", "rule": ['anonymous', '/login', '*']}) 
Out[4]: <QuerySet [<CasbinRule 2: "p, anonymous, /login, *">]>

The filter will probably have to be more complex and the query used should also consider other use cases like loading for a single org etc which I haven't tested. At least I think this could work.

There is still a concern about the performance implications of these queries, though.

2748819

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Thanks for your comment!

Yes, based on what you mentioned, we shouldn’t use the default Middleware. I also tested your implementation, but I wonder if we should do something additional. You’re calling enforcer.adapter.load_filtered_policy, but we should be able to use the enforcer directly like enforcer.load_filtered_policy.

I’m also noticing that if we don’t use the middleware, the django-authorization library isn’t necessary. I’m running some tests using only the Django ORM Adapter.

Copy link
Contributor Author

@BryanttV BryanttV Sep 10, 2025

Choose a reason for hiding this comment

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

Based on your code and the SQLAlchemy adapter, I created this ExtendedAdapter (It may need some optimization, but the adapter seems to work correctly with the new method.)

# openedx_authz.engine.adapter

from casbin import persist
from casbin.persist import FilteredAdapter
from casbin_adapter.adapter import Adapter
from casbin_adapter.models import CasbinRule


class ExtendedAdapter(Adapter, FilteredAdapter):
    """
    Extended adapter for the casbin model.
    """

    _filtered = False

    def load_filtered_policy(self, model, filter) -> None:  # pylint: disable=redefined-builtin
        """loads all policy rules from the storage."""
        queryset = CasbinRule.objects.using(self.db_alias).all()
        filtered_queryset = self.filter_query(queryset, filter)
        for line in filtered_queryset:
            persist.load_policy_line(str(line), model)
        self._filtered = True

    def filter_query(self, queryset, filter):  # pylint: disable=redefined-builtin
        """filters the queryset based on the attributes of the filter."""
        for attr in ("ptype", "v0", "v1", "v2", "v3", "v4", "v5"):
            filter_values = getattr(filter, attr)
            if len(filter_values) > 0:
                filter_kwargs = {f"{attr}__in": filter_values}
                queryset = queryset.filter(**filter_kwargs)

        return queryset.order_by("id")
# openedx_authz.engine.filter

class Filter:
    """
    Filter class for the casbin model.
    """

    ptype = []
    v0 = []
    v1 = []
    v2 = []
    v3 = []
    v4 = []
    v5 = []

I did some tests, and it seems to work:

In [1]: from openedx_authz.engine.filter import Filter

In [2]: from casbin_adapter.enforcer import enforcer as e

In [3]: f = Filter()

In [4]: f.v0 = ["alice"]

In [5]: e.load_filtered_policy(f)
2025-09-10 22:52:36,830 INFO 147 [casbin.policy] [user None] [ip None] policy.py:73 - Policy:
2025-09-10 22:52:36,830 INFO 147 [casbin.policy] [user None] [ip None] policy.py:79 - p : sub, obj, act : [['alice', 'data1', 'read']]
2025-09-10 22:52:36,831 INFO 147 [casbin.policy] [user None] [ip None] policy.py:79 - g : _, _ : [['alice', 'data2_admin']]
2025-09-10 22:52:36,831 INFO 147 [casbin.policy] [user None] [ip None] assertion.py:48 - Role links for: g
2025-09-10 22:52:36,831 INFO 147 [casbin.role] [user None] [ip None] role_manager.py:218 - alice < data2_admin

In [6]: e.get_policy()
Out[6]: [['alice', 'data1', 'read']]

In [7]: f.v0 = ["bob"]

In [8]: e.load_filtered_policy(f)
2025-09-10 22:52:48,271 INFO 147 [casbin.policy] [user None] [ip None] policy.py:73 - Policy:
2025-09-10 22:52:48,272 INFO 147 [casbin.policy] [user None] [ip None] policy.py:79 - p : sub, obj, act : [['bob', 'data2', 'write']]
2025-09-10 22:52:48,272 INFO 147 [casbin.policy] [user None] [ip None] policy.py:79 - g : _, _ : []
2025-09-10 22:52:48,272 INFO 147 [casbin.policy] [user None] [ip None] assertion.py:48 - Role links for: g
2025-09-10 22:52:48,272 INFO 147 [casbin.role] [user None] [ip None] role_manager.py:218 - 

In [9]: e.get_policy()
Out[9]: [['bob', 'data2', 'write']]

Copy link
Contributor Author

Choose a reason for hiding this comment

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

This is the new Casbin config using only the Django ORM Adapter:

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, "model.conf")
watcher_options = WatcherOptions()
watcher_options.host = "redis"
watcher_options.port = 6379
watcher_options.optional_update_callback = callback_function
watcher = new_watcher(watcher_options)
settings.CASBIN_WATCHER = watcher
settings.CASBIN_ADAPTER = "openedx_authz.engine.adapter.ExtendedAdapter"

Copy link
Member

@mariajgrimaldi mariajgrimaldi Sep 12, 2025

Choose a reason for hiding this comment

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

Great! Where can we get the redis configurations though? I see it's in the CACHE dictionary or maybe this could be part of a casbin.py on each installation - which is not tied directly to lms/cms

settings.MIDDLEWARE = settings.MIDDLEWARE + [middleware_class]

# Add authorization configuration
settings.CASBIN_MODEL = os.path.join(ROOT_DIRECTORY, "model.conf")
Copy link
Member

Choose a reason for hiding this comment

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

This should be a file part of a tutor plugin instead so it can be loaded and accessed as a volume and properly maintained by operators.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I agree!

settings.DAUTHZ = {
"DEFAULT": {
"MODEL": {
"CONFIG_TYPE": "file",
"CONFIG_FILE_PATH": settings.CASBIN_MODEL,
"CONFIG_TEXT": "",
},
"ADAPTER": {
"NAME": "casbin_adapter.adapter.Adapter",
},
"LOG": {
"ENABLED": True,
},
},
}
15 changes: 15 additions & 0 deletions openedx_authz/settings/production.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
"""
Production settings for openedx_authz plugin.
"""


def plugin_settings(settings):
"""
Configure plugin settings for Open edX.

This function is called by the Open edX plugin system to configure
the Django settings for this plugin.

Args:
settings: The Django settings object
"""
50 changes: 50 additions & 0 deletions openedx_authz/settings/test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
"""
Test settings for openedx_authz plugin.
"""

import os

from openedx_authz import ROOT_DIRECTORY


def plugin_settings(settings):
"""
Configure plugin settings for Open edX.

This function is called by the Open edX plugin system to configure
the Django settings for this plugin.

Args:
settings: The Django settings object
"""
# Add external third-party apps to INSTALLED_APPS
external_apps = [
"casbin_adapter.apps.CasbinAdapterConfig", # Casbin Adapter
"dauthz.apps.DauthzConfig", # Django Authorization library
]

for app in external_apps:
if app not in settings.INSTALLED_APPS:
settings.INSTALLED_APPS.append(app)

# Add middleware for authorization
middleware_class = "dauthz.middlewares.request_middleware.RequestMiddleware"
settings.MIDDLEWARE = settings.MIDDLEWARE + [middleware_class]

# Add authorization configuration
settings.CASBIN_MODEL = os.path.join(ROOT_DIRECTORY, "model.conf")
settings.DAUTHZ = {
"DEFAULT": {
"MODEL": {
"CONFIG_TYPE": "file",
"CONFIG_FILE_PATH": settings.CASBIN_MODEL,
"CONFIG_TEXT": "",
},
"ADAPTER": {
"NAME": "casbin_adapter.adapter.Adapter",
},
"LOG": {
"ENABLED": True,
},
},
}
15 changes: 11 additions & 4 deletions openedx_authz/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,17 @@
URLs for openedx_authz.
"""

from django.urls import re_path # pylint: disable=unused-import
from django.views.generic import TemplateView # pylint: disable=unused-import
from django.urls import include, re_path
from rest_framework.routers import DefaultRouter

from .views import AdminRoleAssignmentViewSet, LibraryViewSet, PolicyBulkViewSet, PolicySingleViewSet

router = DefaultRouter()
router.register(r"libraries", LibraryViewSet, basename="library")
router.register(r"admin-roles", AdminRoleAssignmentViewSet, basename="admin-roles")
router.register(r"policy-bulk", PolicyBulkViewSet, basename="policy-bulk")
router.register(r"policy-single", PolicySingleViewSet, basename="policy-single")

urlpatterns = [
# TODO: Fill in URL patterns and views here.
# re_path(r'', TemplateView.as_view(template_name="openedx_authz/base.html")),
re_path(r"^api/", include(router.urls)),
]
Loading
Loading