From d3c3487da6aff996b614127fe361b0e03c740b87 Mon Sep 17 00:00:00 2001 From: Bryann Valderrama Date: Tue, 2 Sep 2025 13:15:41 -0500 Subject: [PATCH 01/27] chore: add casbin in base requirements --- requirements/base.in | 1 + requirements/base.txt | 4 ++++ requirements/dev.txt | 6 ++++++ requirements/doc.txt | 6 ++++++ requirements/quality.txt | 6 ++++++ requirements/test.txt | 6 ++++++ 6 files changed, 29 insertions(+) diff --git a/requirements/base.in b/requirements/base.in index 9f4002ee..48c5989d 100644 --- a/requirements/base.in +++ b/requirements/base.in @@ -5,3 +5,4 @@ Django # Web application framework openedx-atlas +casbin # Authorization library diff --git a/requirements/base.txt b/requirements/base.txt index d7690f80..fa12669e 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -6,11 +6,15 @@ # asgiref==3.9.1 # via django +casbin==1.43.0 + # via -r requirements/base.in django==4.2.23 # via # -c https:/raw.githubusercontent.com/edx/edx-lint/master/edx_lint/files/common_constraints.txt # -r requirements/base.in openedx-atlas==0.7.0 # via -r requirements/base.in +simpleeval==1.0.3 + # via casbin sqlparse==0.5.3 # via django diff --git a/requirements/dev.txt b/requirements/dev.txt index 47bf74ab..8af671df 100644 --- a/requirements/dev.txt +++ b/requirements/dev.txt @@ -21,6 +21,8 @@ cachetools==6.2.0 # via # -r requirements/ci.txt # tox +casbin==1.43.0 + # via -r requirements/quality.txt chardet==5.2.0 # via # -r requirements/ci.txt @@ -189,6 +191,10 @@ pyyaml==6.0.2 # -r requirements/quality.txt # code-annotations # edx-i18n-tools +simpleeval==1.0.3 + # via + # -r requirements/quality.txt + # casbin six==1.17.0 # via # -r requirements/quality.txt diff --git a/requirements/doc.txt b/requirements/doc.txt index a16c0732..a642267c 100644 --- a/requirements/doc.txt +++ b/requirements/doc.txt @@ -22,6 +22,8 @@ beautifulsoup4==4.13.5 # via pydata-sphinx-theme build==1.3.0 # via -r requirements/doc.in +casbin==1.43.0 + # via -r requirements/test.txt certifi==2025.8.3 # via requests cffi==1.17.1 @@ -164,6 +166,10 @@ roman-numerals-py==3.1.0 # via sphinx secretstorage==3.3.3 # via keyring +simpleeval==1.0.3 + # via + # -r requirements/test.txt + # casbin snowballstemmer==3.0.1 # via sphinx soupsieve==2.8 diff --git a/requirements/quality.txt b/requirements/quality.txt index 0340e588..807efd8f 100644 --- a/requirements/quality.txt +++ b/requirements/quality.txt @@ -12,6 +12,8 @@ astroid==3.3.11 # via # pylint # pylint-celery +casbin==1.43.0 + # via -r requirements/test.txt click==8.2.1 # via # -r requirements/test.txt @@ -106,6 +108,10 @@ pyyaml==6.0.2 # via # -r requirements/test.txt # code-annotations +simpleeval==1.0.3 + # via + # -r requirements/test.txt + # casbin six==1.17.0 # via edx-lint snowballstemmer==3.0.1 diff --git a/requirements/test.txt b/requirements/test.txt index 9cf395ae..b956586e 100644 --- a/requirements/test.txt +++ b/requirements/test.txt @@ -8,6 +8,8 @@ asgiref==3.9.1 # via # -r requirements/base.txt # django +casbin==1.43.0 + # via -r requirements/base.txt click==8.2.1 # via code-annotations code-annotations==2.3.0 @@ -45,6 +47,10 @@ python-slugify==8.0.4 # via code-annotations pyyaml==6.0.2 # via code-annotations +simpleeval==1.0.3 + # via + # -r requirements/base.txt + # casbin sqlparse==0.5.3 # via # -r requirements/base.txt From 4ba8fa90519ce316962a9825f7426007f8a88b2f Mon Sep 17 00:00:00 2001 From: Bryann Valderrama Date: Wed, 3 Sep 2025 16:28:10 -0500 Subject: [PATCH 02/27] chore: add casbin and drf in base requirements --- requirements/base.in | 6 ++++-- requirements/base.txt | 15 ++++++++++++++- requirements/dev.txt | 16 ++++++++++++++++ requirements/doc.txt | 16 ++++++++++++++++ requirements/quality.txt | 16 ++++++++++++++++ requirements/test.txt | 16 ++++++++++++++++ 6 files changed, 82 insertions(+), 3 deletions(-) diff --git a/requirements/base.in b/requirements/base.in index 48c5989d..d5e06ccb 100644 --- a/requirements/base.in +++ b/requirements/base.in @@ -1,8 +1,10 @@ # Core requirements for using this application -c constraints.txt -Django # Web application framework +Django # Web application framework +djangorestframework # Django REST framework for API development openedx-atlas -casbin # Authorization library +casbin-django-orm-adapter # Casbin Django ORM adapter +django_authorization # Django Authorization library diff --git a/requirements/base.txt b/requirements/base.txt index fa12669e..027f714b 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -7,14 +7,27 @@ asgiref==3.9.1 # via django casbin==1.43.0 + # via django-authorization +casbin-django-orm-adapter==1.7.0 # via -r requirements/base.in django==4.2.23 # via # -c https:/raw.githubusercontent.com/edx/edx-lint/master/edx_lint/files/common_constraints.txt # -r requirements/base.in + # casbin-django-orm-adapter + # django-authorization + # djangorestframework +django-authorization==1.4.0 + # via -r requirements/base.in +djangorestframework==3.16.1 + # via -r requirements/base.in openedx-atlas==0.7.0 # via -r requirements/base.in +pycasbin==2.2.0 + # via casbin-django-orm-adapter simpleeval==1.0.3 - # via casbin + # via + # casbin + # pycasbin sqlparse==0.5.3 # via django diff --git a/requirements/dev.txt b/requirements/dev.txt index 8af671df..48f814f1 100644 --- a/requirements/dev.txt +++ b/requirements/dev.txt @@ -22,6 +22,10 @@ cachetools==6.2.0 # -r requirements/ci.txt # tox casbin==1.43.0 + # via + # -r requirements/quality.txt + # django-authorization +casbin-django-orm-adapter==1.7.0 # via -r requirements/quality.txt chardet==5.2.0 # via @@ -66,7 +70,14 @@ django==4.2.23 # via # -c https:/raw.githubusercontent.com/edx/edx-lint/master/edx_lint/files/common_constraints.txt # -r requirements/quality.txt + # casbin-django-orm-adapter + # django-authorization + # djangorestframework # edx-i18n-tools +django-authorization==1.4.0 + # via -r requirements/quality.txt +djangorestframework==3.16.1 + # via -r requirements/quality.txt edx-i18n-tools==1.9.0 # via -r requirements/dev.in edx-lint==5.6.0 @@ -135,6 +146,10 @@ pluggy==1.6.0 # tox polib==1.2.0 # via edx-i18n-tools +pycasbin==2.2.0 + # via + # -r requirements/quality.txt + # casbin-django-orm-adapter pycodestyle==2.14.0 # via -r requirements/quality.txt pydocstyle==6.3.0 @@ -195,6 +210,7 @@ simpleeval==1.0.3 # via # -r requirements/quality.txt # casbin + # pycasbin six==1.17.0 # via # -r requirements/quality.txt diff --git a/requirements/doc.txt b/requirements/doc.txt index a642267c..f8423260 100644 --- a/requirements/doc.txt +++ b/requirements/doc.txt @@ -23,6 +23,10 @@ beautifulsoup4==4.13.5 build==1.3.0 # via -r requirements/doc.in casbin==1.43.0 + # via + # -r requirements/test.txt + # django-authorization +casbin-django-orm-adapter==1.7.0 # via -r requirements/test.txt certifi==2025.8.3 # via requests @@ -46,6 +50,13 @@ django==4.2.23 # via # -c https:/raw.githubusercontent.com/edx/edx-lint/master/edx_lint/files/common_constraints.txt # -r requirements/test.txt + # casbin-django-orm-adapter + # django-authorization + # djangorestframework +django-authorization==1.4.0 + # via -r requirements/test.txt +djangorestframework==3.16.1 + # via -r requirements/test.txt doc8==2.0.0 # via -r requirements/doc.in docutils==0.21.2 @@ -113,6 +124,10 @@ pluggy==1.6.0 # -r requirements/test.txt # pytest # pytest-cov +pycasbin==2.2.0 + # via + # -r requirements/test.txt + # casbin-django-orm-adapter pycparser==2.22 # via cffi pydata-sphinx-theme==0.15.4 @@ -170,6 +185,7 @@ simpleeval==1.0.3 # via # -r requirements/test.txt # casbin + # pycasbin snowballstemmer==3.0.1 # via sphinx soupsieve==2.8 diff --git a/requirements/quality.txt b/requirements/quality.txt index 807efd8f..0048dbf3 100644 --- a/requirements/quality.txt +++ b/requirements/quality.txt @@ -13,6 +13,10 @@ astroid==3.3.11 # pylint # pylint-celery casbin==1.43.0 + # via + # -r requirements/test.txt + # django-authorization +casbin-django-orm-adapter==1.7.0 # via -r requirements/test.txt click==8.2.1 # via @@ -36,6 +40,13 @@ django==4.2.23 # via # -c https:/raw.githubusercontent.com/edx/edx-lint/master/edx_lint/files/common_constraints.txt # -r requirements/test.txt + # casbin-django-orm-adapter + # django-authorization + # djangorestframework +django-authorization==1.4.0 + # via -r requirements/test.txt +djangorestframework==3.16.1 + # via -r requirements/test.txt edx-lint==5.6.0 # via -r requirements/quality.in iniconfig==2.1.0 @@ -69,6 +80,10 @@ pluggy==1.6.0 # -r requirements/test.txt # pytest # pytest-cov +pycasbin==2.2.0 + # via + # -r requirements/test.txt + # casbin-django-orm-adapter pycodestyle==2.14.0 # via -r requirements/quality.in pydocstyle==6.3.0 @@ -112,6 +127,7 @@ simpleeval==1.0.3 # via # -r requirements/test.txt # casbin + # pycasbin six==1.17.0 # via edx-lint snowballstemmer==3.0.1 diff --git a/requirements/test.txt b/requirements/test.txt index b956586e..2f0e2551 100644 --- a/requirements/test.txt +++ b/requirements/test.txt @@ -9,6 +9,10 @@ asgiref==3.9.1 # -r requirements/base.txt # django casbin==1.43.0 + # via + # -r requirements/base.txt + # django-authorization +casbin-django-orm-adapter==1.7.0 # via -r requirements/base.txt click==8.2.1 # via code-annotations @@ -19,6 +23,13 @@ coverage[toml]==7.10.5 # via # -c https:/raw.githubusercontent.com/edx/edx-lint/master/edx_lint/files/common_constraints.txt # -r requirements/base.txt + # casbin-django-orm-adapter + # django-authorization + # djangorestframework +django-authorization==1.4.0 + # via -r requirements/base.txt +djangorestframework==3.16.1 + # via -r requirements/base.txt iniconfig==2.1.0 # via pytest jinja2==3.1.6 @@ -33,6 +44,10 @@ pluggy==1.6.0 # via # pytest # pytest-cov +pycasbin==2.2.0 + # via + # -r requirements/base.txt + # casbin-django-orm-adapter pygments==2.19.2 # via pytest pytest==8.4.1 @@ -51,6 +66,7 @@ simpleeval==1.0.3 # via # -r requirements/base.txt # casbin + # pycasbin sqlparse==0.5.3 # via # -r requirements/base.txt From a2feec6dc3f58e547149886095edd3664b878fe7 Mon Sep 17 00:00:00 2001 From: Bryann Valderrama Date: Wed, 3 Sep 2025 16:34:17 -0500 Subject: [PATCH 03/27] feat: add settings --- openedx_authz/settings/__init__.py | 3 ++ openedx_authz/settings/common.py | 49 +++++++++++++++++++++++++++ openedx_authz/settings/production.py | 15 +++++++++ openedx_authz/settings/test.py | 50 ++++++++++++++++++++++++++++ 4 files changed, 117 insertions(+) create mode 100644 openedx_authz/settings/__init__.py create mode 100644 openedx_authz/settings/common.py create mode 100644 openedx_authz/settings/production.py create mode 100644 openedx_authz/settings/test.py diff --git a/openedx_authz/settings/__init__.py b/openedx_authz/settings/__init__.py new file mode 100644 index 00000000..8403e6de --- /dev/null +++ b/openedx_authz/settings/__init__.py @@ -0,0 +1,3 @@ +""" +Settings package for openedx_authz plugin. +""" diff --git a/openedx_authz/settings/common.py b/openedx_authz/settings/common.py new file mode 100644 index 00000000..1abbc105 --- /dev/null +++ b/openedx_authz/settings/common.py @@ -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" + # 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, + }, + }, + } diff --git a/openedx_authz/settings/production.py b/openedx_authz/settings/production.py new file mode 100644 index 00000000..4f80cf42 --- /dev/null +++ b/openedx_authz/settings/production.py @@ -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 + """ diff --git a/openedx_authz/settings/test.py b/openedx_authz/settings/test.py new file mode 100644 index 00000000..9d825df1 --- /dev/null +++ b/openedx_authz/settings/test.py @@ -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, + }, + }, + } From 97ef2a752a602a9a4821e60525b2722af29b229e Mon Sep 17 00:00:00 2001 From: Bryann Valderrama Date: Wed, 3 Sep 2025 16:35:02 -0500 Subject: [PATCH 04/27] feat: add entry points --- setup.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/setup.py b/setup.py index b0f66915..217bb067 100755 --- a/setup.py +++ b/setup.py @@ -159,4 +159,12 @@ def is_requirement(line): "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", ], + entry_points={ + "lms.djangoapp": [ + "openedx_authz = openedx_authz.apps:OpenedxAuthzConfig", + ], + "cms.djangoapp": [ + "openedx_authz = openedx_authz.apps:OpenedxAuthzConfig", + ], + }, ) From a64f2547e3998b33c443b315831e0e3236fc38b4 Mon Sep 17 00:00:00 2001 From: Bryann Valderrama Date: Wed, 3 Sep 2025 16:35:46 -0500 Subject: [PATCH 05/27] feat: add basic dummy api --- openedx_authz/models.py | 28 +++++ openedx_authz/serializers.py | 73 ++++++++++++ openedx_authz/urls.py | 11 +- openedx_authz/views.py | 223 +++++++++++++++++++++++++++++++++++ 4 files changed, 331 insertions(+), 4 deletions(-) create mode 100644 openedx_authz/serializers.py create mode 100644 openedx_authz/views.py diff --git a/openedx_authz/models.py b/openedx_authz/models.py index 8297668b..4104bb5e 100644 --- a/openedx_authz/models.py +++ b/openedx_authz/models.py @@ -1,3 +1,31 @@ """ 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") + num_blocks = models.IntegerField(default=0, help_text="Number of blocks in the library") + version = models.IntegerField(default=0, help_text="Library version") + allow_public_read = models.BooleanField(default=True, help_text="Allow public read access") + can_edit_library = models.BooleanField(default=False, help_text="Whether user can edit this library") + created = models.DateTimeField(null=True, blank=True, help_text="Creation timestamp") + updated = models.DateTimeField(null=True, blank=True, help_text="Last update timestamp") + + class Meta: + verbose_name = "Library" + verbose_name_plural = "Libraries" + ordering = ["title"] + + def __str__(self): + return str(self.title) diff --git a/openedx_authz/serializers.py b/openedx_authz/serializers.py new file mode 100644 index 00000000..e92640ad --- /dev/null +++ b/openedx_authz/serializers.py @@ -0,0 +1,73 @@ +""" +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", + "num_blocks", + "version", + "allow_public_read", + "can_edit_library", + "created", + "updated", + ] + read_only_fields = ["created", "updated"] + + def validate_num_blocks(self, value): + """ + Validate that num_blocks is not negative. + """ + if value < 0: + raise serializers.ValidationError("Number of blocks cannot be negative.") + return value + + def validate_title(self, value): + """ + Validate that title is not empty. + """ + if not value.strip(): + raise serializers.ValidationError("Library title cannot be empty.") + return value.strip() + + def validate_org(self, value): + """ + Validate that organization is not empty. + """ + if not value.strip(): + raise serializers.ValidationError("Organization cannot be empty.") + return value.strip() + + def validate_slug(self, value): + """ + Validate that slug is not empty. + """ + if not value.strip(): + raise serializers.ValidationError("Library slug cannot be empty.") + return value.strip() + + def validate(self, data): + """ + Validate that the ID matches the org:slug format if provided. + """ + if "id" in data and "org" in data and "slug" in data: + expected_id = f"lib:{data['org']}:{data['slug']}" + if data["id"] != expected_id: + raise serializers.ValidationError( + f"Library ID should be in format 'lib:ORG:SLUG'. Expected: {expected_id}" + ) + return data diff --git a/openedx_authz/urls.py b/openedx_authz/urls.py index 615cef5b..d03f3f0e 100644 --- a/openedx_authz/urls.py +++ b/openedx_authz/urls.py @@ -2,10 +2,13 @@ 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 re_path, include +from rest_framework.routers import DefaultRouter +from .views import LibraryViewSet + +router = DefaultRouter() +router.register(r"libraries", LibraryViewSet, basename="library") 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)), ] diff --git a/openedx_authz/views.py b/openedx_authz/views.py new file mode 100644 index 00000000..bb617dd4 --- /dev/null +++ b/openedx_authz/views.py @@ -0,0 +1,223 @@ +""" +Views for openedx_authz DRF API. +""" + +from dauthz.core import enforcer +from rest_framework import status, viewsets +from rest_framework.response import Response + +from .serializers import LibrarySerializer + + +def load_policy(): + """ + Load the policy for the openedx_authz Django application. + """ + # Moving this import outside the function break the code :) + # from dauthz.core import enforcer # pylint: disable=import-outside-toplevel + + p_rules = [ + ["anonymous", "/", "(GET)|(POST)"], + ["admin", "/*", "(GET)|(POST)|(PUT)|(DELETE)|(PATCH)"], + ] + g_rules = [ + ["normal_user", "anonymous"], + ["admin", "normal_user"], + ] + enforcer.add_policies(p_rules) + enforcer.add_grouping_policies(g_rules) + enforcer.save_policy() + print("\n\nPolicy loaded...\n\n") + + +class LibraryViewSet(viewsets.ViewSet): + """ + A ViewSet for handling Library operations with dummy data. + Provides all HTTP methods: GET, POST, PUT, PATCH, DELETE + """ + + # Dummy data for OpenedX libraries + dummy_libraries = [ + { + "id": "lib:OpenedX:LIBEXAMPLE", + "org": "OpenedX", + "slug": "LIBEXAMPLE", + "title": "Library Example", + "description": "Example library for demonstration purposes", + "num_blocks": 15, + "version": 2, + "allow_public_read": True, + "can_edit_library": False, + "created": "2023-01-15T10:00:00Z", + "updated": "2024-01-15T10:00:00Z", + }, + { + "id": "lib:AXIM:SecondLibrary", + "org": "AXIM", + "slug": "SecondLibrary", + "title": "Second Library Example", + "description": "Another example library for testing", + "num_blocks": 8, + "version": 1, + "allow_public_read": False, + "can_edit_library": False, + "created": "2023-02-20T14:30:00Z", + "updated": "2024-02-20T14:30:00Z", + }, + { + "id": "lib:MITx:PythonLibrary", + "org": "MITx", + "slug": "PythonLibrary", + "title": "Python Programming Library", + "description": "Library containing Python programming exercises and examples", + "num_blocks": 25, + "version": 3, + "allow_public_read": True, + "can_edit_library": True, + "created": "2023-03-10T09:15:00Z", + "updated": "2024-03-10T09:15:00Z", + }, + { + "id": "lib:HarvardX:MathLibrary", + "org": "HarvardX", + "slug": "MathLibrary", + "title": "Mathematics Content Library", + "description": "", + "num_blocks": 0, + "version": 0, + "allow_public_read": True, + "can_edit_library": False, + "created": None, + "updated": None, + }, + ] + + def list(self, request): + """ + GET /libraries/ + Return a list of all libraries. + """ + print("\n\nListing libraries...\n\n") + load_policy() + breakpoint() + + enforcer.enforce("admin", "/libraries/", "(GET)|(POST)|(PUT)|(DELETE)|(PATCH)") + + # Filter by public read access if requested + allow_public_read = request.query_params.get("allow_public_read") + libraries = self.dummy_libraries + + if allow_public_read is not None: + allow_public_read_bool = allow_public_read.lower() in ["true", "1", "yes"] + libraries = [lib for lib in libraries if lib["allow_public_read"] == allow_public_read_bool] + + # Filter by organization if requested + org = request.query_params.get("org") + if org: + libraries = [lib for lib in libraries if lib["org"].lower() == org.lower()] + + # Search by title if requested + search = request.query_params.get("search") + if search: + libraries = [lib for lib in libraries if search.lower() in lib["title"].lower()] + + return Response({"count": len(libraries), "results": libraries}) + + def create(self, request): + """ + POST /libraries/ + Create a new library. + """ + serializer = LibrarySerializer(data=request.data) + if serializer.is_valid(): + # Generate library ID if not provided + validated_data = serializer.validated_data + if "id" not in validated_data: + validated_data["id"] = f"lib:{validated_data['org']}:{validated_data['slug']}" + + new_library = { + **validated_data, + "created": "2024-01-01T12:00:00Z", + "updated": "2024-01-01T12:00:00Z", + } + + # Add to dummy data (in real app, this would save to database) + self.dummy_libraries.append(new_library) + + return Response(new_library, status=status.HTTP_201_CREATED) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + def retrieve(self, request, pk=None): + """ + GET /libraries/{id}/ + Retrieve a specific library by ID. + """ + library = next((lib for lib in self.dummy_libraries if lib["id"] == pk), None) + + if library: + return Response(library) + else: + return Response({"detail": "Library not found."}, status=status.HTTP_404_NOT_FOUND) + + def update(self, request, pk=None): + """ + PUT /libraries/{id}/ + Update a library completely. + """ + library_index = next((i for i, lib in enumerate(self.dummy_libraries) if lib["id"] == pk), None) + + if library_index is not None: + serializer = LibrarySerializer(data=request.data) + if serializer.is_valid(): + # Update the library with new data + updated_library = { + **serializer.validated_data, + "created": self.dummy_libraries[library_index]["created"], + "updated": "2024-01-01T12:00:00Z", + } + + self.dummy_libraries[library_index] = updated_library + return Response(updated_library) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + else: + return Response({"detail": "Library not found."}, status=status.HTTP_404_NOT_FOUND) + + def partial_update(self, request, pk=None): + """ + PATCH /libraries/{id}/ + Partially update a library. + """ + library_index = next((i for i, lib in enumerate(self.dummy_libraries) if lib["id"] == pk), None) + + if library_index is not None: + current_library = self.dummy_libraries[library_index].copy() + serializer = LibrarySerializer(data=request.data, partial=True) + + if serializer.is_valid(): + # Update only the provided fields + for field, value in serializer.validated_data.items(): + current_library[field] = value + + current_library["updated"] = "2024-01-01T12:00:00Z" + self.dummy_libraries[library_index] = current_library + + return Response(current_library) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + else: + return Response({"detail": "Library not found."}, status=status.HTTP_404_NOT_FOUND) + + def destroy(self, request, pk=None): + """ + DELETE /libraries/{id}/ + Delete a library. + """ + library_index = next((i for i, lib in enumerate(self.dummy_libraries) if lib["id"] == pk), None) + + if library_index is not None: + deleted_library = self.dummy_libraries.pop(library_index) + return Response( + {"detail": f'Library "{deleted_library["title"]}" has been deleted.'}, + status=status.HTTP_204_NO_CONTENT, + ) + else: + return Response({"detail": "Library not found."}, status=status.HTTP_404_NOT_FOUND) From 24b5da489e7e7d03c64359e7fa22719d454d0b2b Mon Sep 17 00:00:00 2001 From: Bryann Valderrama Date: Wed, 3 Sep 2025 16:35:59 -0500 Subject: [PATCH 06/27] feat: add root_directory --- openedx_authz/__init__.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/openedx_authz/__init__.py b/openedx_authz/__init__.py index 15602551..203c0264 100644 --- a/openedx_authz/__init__.py +++ b/openedx_authz/__init__.py @@ -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__)) From a7e5e48d746f7a4c385750d24ab0eb9646e08a7b Mon Sep 17 00:00:00 2001 From: Bryann Valderrama Date: Wed, 3 Sep 2025 16:36:20 -0500 Subject: [PATCH 07/27] feat: add plugin_app config --- openedx_authz/apps.py | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/openedx_authz/apps.py b/openedx_authz/apps.py index eb3e05eb..2f7855f7 100644 --- a/openedx_authz/apps.py +++ b/openedx_authz/apps.py @@ -11,3 +11,32 @@ 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"}, + }, + }, + } From 665945701bb21fd5b303d77f1382d0e48778e629 Mon Sep 17 00:00:00 2001 From: Bryann Valderrama Date: Wed, 3 Sep 2025 16:36:36 -0500 Subject: [PATCH 08/27] feat: add model.conf for casbin --- openedx_authz/model.conf | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 openedx_authz/model.conf diff --git a/openedx_authz/model.conf b/openedx_authz/model.conf new file mode 100644 index 00000000..0aee6497 --- /dev/null +++ b/openedx_authz/model.conf @@ -0,0 +1,29 @@ +# [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) && keyMatch2(r.obj, p.obj) && r.act == p.act + +[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) && r.obj == p.obj && regexMatch(r.act, p.act) \ No newline at end of file From 212d6ea29534e0d66ca163686aff86ddb8a04671 Mon Sep 17 00:00:00 2001 From: Bryann Valderrama Date: Wed, 3 Sep 2025 19:44:20 -0500 Subject: [PATCH 09/27] chore: use the correct test file --- manage.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/manage.py b/manage.py index f45575c0..e6954dd0 100644 --- a/manage.py +++ b/manage.py @@ -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 From 0bb8fcb0a542d1c4e09e633b5575f50985fe5763 Mon Sep 17 00:00:00 2001 From: Bryann Valderrama Date: Wed, 3 Sep 2025 19:45:15 -0500 Subject: [PATCH 10/27] feat: remove keyMatch2 in the matcher --- openedx_authz/model.conf | 17 +---------------- 1 file changed, 1 insertion(+), 16 deletions(-) diff --git a/openedx_authz/model.conf b/openedx_authz/model.conf index 0aee6497..459a15fc 100644 --- a/openedx_authz/model.conf +++ b/openedx_authz/model.conf @@ -1,18 +1,3 @@ -# [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) && keyMatch2(r.obj, p.obj) && r.act == p.act - [request_definition] r = sub, obj, act @@ -26,4 +11,4 @@ g = _, _ e = some(where (p.eft == allow)) [matchers] -m = g(r.sub, p.sub) && r.obj == p.obj && regexMatch(r.act, p.act) \ No newline at end of file +m = g(r.sub, p.sub) && r.obj == p.obj && regexMatch(r.act, p.act) From be6add0186d2695462d0d3d242ebd223783d75ac Mon Sep 17 00:00:00 2001 From: Bryann Valderrama Date: Wed, 3 Sep 2025 19:45:36 -0500 Subject: [PATCH 11/27] chore: add casbin middleware --- openedx_authz/settings/common.py | 4 ++-- openedx_authz/settings/test.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/openedx_authz/settings/common.py b/openedx_authz/settings/common.py index 1abbc105..e8714827 100644 --- a/openedx_authz/settings/common.py +++ b/openedx_authz/settings/common.py @@ -27,8 +27,8 @@ def plugin_settings(settings): settings.INSTALLED_APPS.append(app) # Add middleware for authorization - # middleware_class = "dauthz.middlewares.request_middleware.RequestMiddleware" - # settings.MIDDLEWARE = settings.MIDDLEWARE + [middleware_class] + 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") diff --git a/openedx_authz/settings/test.py b/openedx_authz/settings/test.py index 9d825df1..c4729d53 100644 --- a/openedx_authz/settings/test.py +++ b/openedx_authz/settings/test.py @@ -28,8 +28,8 @@ def plugin_settings(settings): settings.INSTALLED_APPS.append(app) # Add middleware for authorization - # middleware_class = "dauthz.middlewares.request_middleware.RequestMiddleware" - # settings.MIDDLEWARE = settings.MIDDLEWARE + [middleware_class] + 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") From e484a055d6de20acd853706efb2f383016d9068e Mon Sep 17 00:00:00 2001 From: Bryann Valderrama Date: Wed, 3 Sep 2025 19:46:05 -0500 Subject: [PATCH 12/27] chore: remove extra fields of library model --- openedx_authz/models.py | 6 ------ 1 file changed, 6 deletions(-) diff --git a/openedx_authz/models.py b/openedx_authz/models.py index 4104bb5e..313fe4c2 100644 --- a/openedx_authz/models.py +++ b/openedx_authz/models.py @@ -15,12 +15,6 @@ class Library(models.Model): 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") - num_blocks = models.IntegerField(default=0, help_text="Number of blocks in the library") - version = models.IntegerField(default=0, help_text="Library version") - allow_public_read = models.BooleanField(default=True, help_text="Allow public read access") - can_edit_library = models.BooleanField(default=False, help_text="Whether user can edit this library") - created = models.DateTimeField(null=True, blank=True, help_text="Creation timestamp") - updated = models.DateTimeField(null=True, blank=True, help_text="Last update timestamp") class Meta: verbose_name = "Library" From 26fdd1cd9a7ea69209e5b41576fdb16f3b0ad780 Mon Sep 17 00:00:00 2001 From: Bryann Valderrama Date: Wed, 3 Sep 2025 19:46:29 -0500 Subject: [PATCH 13/27] refactor: remove validators and extra field from the serializer --- openedx_authz/serializers.py | 51 ------------------------------------ 1 file changed, 51 deletions(-) diff --git a/openedx_authz/serializers.py b/openedx_authz/serializers.py index e92640ad..1e4a722d 100644 --- a/openedx_authz/serializers.py +++ b/openedx_authz/serializers.py @@ -19,55 +19,4 @@ class Meta: "slug", "title", "description", - "num_blocks", - "version", - "allow_public_read", - "can_edit_library", - "created", - "updated", ] - read_only_fields = ["created", "updated"] - - def validate_num_blocks(self, value): - """ - Validate that num_blocks is not negative. - """ - if value < 0: - raise serializers.ValidationError("Number of blocks cannot be negative.") - return value - - def validate_title(self, value): - """ - Validate that title is not empty. - """ - if not value.strip(): - raise serializers.ValidationError("Library title cannot be empty.") - return value.strip() - - def validate_org(self, value): - """ - Validate that organization is not empty. - """ - if not value.strip(): - raise serializers.ValidationError("Organization cannot be empty.") - return value.strip() - - def validate_slug(self, value): - """ - Validate that slug is not empty. - """ - if not value.strip(): - raise serializers.ValidationError("Library slug cannot be empty.") - return value.strip() - - def validate(self, data): - """ - Validate that the ID matches the org:slug format if provided. - """ - if "id" in data and "org" in data and "slug" in data: - expected_id = f"lib:{data['org']}:{data['slug']}" - if data["id"] != expected_id: - raise serializers.ValidationError( - f"Library ID should be in format 'lib:ORG:SLUG'. Expected: {expected_id}" - ) - return data From a735270834f922af9e234944d553399c80ca07ac Mon Sep 17 00:00:00 2001 From: Bryann Valderrama Date: Wed, 3 Sep 2025 19:46:54 -0500 Subject: [PATCH 14/27] feat: create initial migration --- openedx_authz/migrations/0001_initial.py | 32 ++++++++++++++++++++++++ openedx_authz/migrations/__init__.py | 0 2 files changed, 32 insertions(+) create mode 100644 openedx_authz/migrations/0001_initial.py create mode 100644 openedx_authz/migrations/__init__.py diff --git a/openedx_authz/migrations/0001_initial.py b/openedx_authz/migrations/0001_initial.py new file mode 100644 index 00000000..3c73c058 --- /dev/null +++ b/openedx_authz/migrations/0001_initial.py @@ -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"], + }, + ), + ] diff --git a/openedx_authz/migrations/__init__.py b/openedx_authz/migrations/__init__.py new file mode 100644 index 00000000..e69de29b From 1d9e857b9828562bdd972641dc83e0ffb16a3038 Mon Sep 17 00:00:00 2001 From: Bryann Valderrama Date: Wed, 3 Sep 2025 19:47:10 -0500 Subject: [PATCH 15/27] feat: add library model to admin --- openedx_authz/admin.py | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 openedx_authz/admin.py diff --git a/openedx_authz/admin.py b/openedx_authz/admin.py new file mode 100644 index 00000000..1693aad3 --- /dev/null +++ b/openedx_authz/admin.py @@ -0,0 +1,8 @@ +""" +Admin for openedx_authz. +""" + +from django.contrib import admin +from .models import Library + +admin.site.register(Library) From 9743a3a933466ca0f70138a699c7b9f8170a1506 Mon Sep 17 00:00:00 2001 From: Bryann Valderrama Date: Wed, 3 Sep 2025 19:47:22 -0500 Subject: [PATCH 16/27] feat: use library model in views --- openedx_authz/views.py | 204 ++++++++++------------------------------- 1 file changed, 49 insertions(+), 155 deletions(-) diff --git a/openedx_authz/views.py b/openedx_authz/views.py index bb617dd4..e72f25c7 100644 --- a/openedx_authz/views.py +++ b/openedx_authz/views.py @@ -5,123 +5,51 @@ from dauthz.core import enforcer from rest_framework import status, viewsets from rest_framework.response import Response +from django.shortcuts import get_object_or_404 +from .models import Library from .serializers import LibrarySerializer -def load_policy(): - """ - Load the policy for the openedx_authz Django application. - """ - # Moving this import outside the function break the code :) - # from dauthz.core import enforcer # pylint: disable=import-outside-toplevel - - p_rules = [ - ["anonymous", "/", "(GET)|(POST)"], - ["admin", "/*", "(GET)|(POST)|(PUT)|(DELETE)|(PATCH)"], - ] - g_rules = [ - ["normal_user", "anonymous"], - ["admin", "normal_user"], - ] - enforcer.add_policies(p_rules) - enforcer.add_grouping_policies(g_rules) - enforcer.save_policy() - print("\n\nPolicy loaded...\n\n") - - class LibraryViewSet(viewsets.ViewSet): """ - A ViewSet for handling Library operations with dummy data. + A ViewSet for handling Library operations using the Library model. Provides all HTTP methods: GET, POST, PUT, PATCH, DELETE """ - # Dummy data for OpenedX libraries - dummy_libraries = [ - { - "id": "lib:OpenedX:LIBEXAMPLE", - "org": "OpenedX", - "slug": "LIBEXAMPLE", - "title": "Library Example", - "description": "Example library for demonstration purposes", - "num_blocks": 15, - "version": 2, - "allow_public_read": True, - "can_edit_library": False, - "created": "2023-01-15T10:00:00Z", - "updated": "2024-01-15T10:00:00Z", - }, - { - "id": "lib:AXIM:SecondLibrary", - "org": "AXIM", - "slug": "SecondLibrary", - "title": "Second Library Example", - "description": "Another example library for testing", - "num_blocks": 8, - "version": 1, - "allow_public_read": False, - "can_edit_library": False, - "created": "2023-02-20T14:30:00Z", - "updated": "2024-02-20T14:30:00Z", - }, - { - "id": "lib:MITx:PythonLibrary", - "org": "MITx", - "slug": "PythonLibrary", - "title": "Python Programming Library", - "description": "Library containing Python programming exercises and examples", - "num_blocks": 25, - "version": 3, - "allow_public_read": True, - "can_edit_library": True, - "created": "2023-03-10T09:15:00Z", - "updated": "2024-03-10T09:15:00Z", - }, - { - "id": "lib:HarvardX:MathLibrary", - "org": "HarvardX", - "slug": "MathLibrary", - "title": "Mathematics Content Library", - "description": "", - "num_blocks": 0, - "version": 0, - "allow_public_read": True, - "can_edit_library": False, - "created": None, - "updated": None, - }, - ] - def list(self, request): """ GET /libraries/ Return a list of all libraries. """ print("\n\nListing libraries...\n\n") - load_policy() - breakpoint() - - enforcer.enforce("admin", "/libraries/", "(GET)|(POST)|(PUT)|(DELETE)|(PATCH)") - # Filter by public read access if requested - allow_public_read = request.query_params.get("allow_public_read") - libraries = self.dummy_libraries + # Get all libraries from database + libraries = Library.objects.all() - if allow_public_read is not None: - allow_public_read_bool = allow_public_read.lower() in ["true", "1", "yes"] - libraries = [lib for lib in libraries if lib["allow_public_read"] == allow_public_read_bool] + # Add authorization policies for each library + for library in libraries: + enforcer.add_policy( + self.request.user.username, + f"/openedx-authz/api/libraries/{library.id}/", + "(GET)|(POST)|(PUT)|(DELETE)|(PATCH)", + ) + enforcer.save_policy() # Filter by organization if requested org = request.query_params.get("org") if org: - libraries = [lib for lib in libraries if lib["org"].lower() == org.lower()] + libraries = libraries.filter(org__iexact=org) # Search by title if requested search = request.query_params.get("search") if search: - libraries = [lib for lib in libraries if search.lower() in lib["title"].lower()] + libraries = libraries.filter(title__icontains=search) + + # Serialize the libraries + serializer = LibrarySerializer(libraries, many=True) - return Response({"count": len(libraries), "results": libraries}) + return Response({"count": libraries.count(), "results": serializer.data}) def create(self, request): """ @@ -130,21 +58,8 @@ def create(self, request): """ serializer = LibrarySerializer(data=request.data) if serializer.is_valid(): - # Generate library ID if not provided - validated_data = serializer.validated_data - if "id" not in validated_data: - validated_data["id"] = f"lib:{validated_data['org']}:{validated_data['slug']}" - - new_library = { - **validated_data, - "created": "2024-01-01T12:00:00Z", - "updated": "2024-01-01T12:00:00Z", - } - - # Add to dummy data (in real app, this would save to database) - self.dummy_libraries.append(new_library) - - return Response(new_library, status=status.HTTP_201_CREATED) + library = serializer.save() + return Response(LibrarySerializer(library).data, status=status.HTTP_201_CREATED) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) def retrieve(self, request, pk=None): @@ -152,72 +67,51 @@ def retrieve(self, request, pk=None): GET /libraries/{id}/ Retrieve a specific library by ID. """ - library = next((lib for lib in self.dummy_libraries if lib["id"] == pk), None) + library = get_object_or_404(Library, id=pk) + enforcer.remove_policy( + self.request.user.username, f"/openedx-authz/api/libraries/{pk}/", "(GET)|(POST)|(PUT)|(DELETE)|(PATCH)" + ) + enforcer.save_policy() - if library: - return Response(library) - else: - return Response({"detail": "Library not found."}, status=status.HTTP_404_NOT_FOUND) + serializer = LibrarySerializer(library) + return Response(serializer.data) def update(self, request, pk=None): """ PUT /libraries/{id}/ Update a library completely. """ - library_index = next((i for i, lib in enumerate(self.dummy_libraries) if lib["id"] == pk), None) - - if library_index is not None: - serializer = LibrarySerializer(data=request.data) - if serializer.is_valid(): - # Update the library with new data - updated_library = { - **serializer.validated_data, - "created": self.dummy_libraries[library_index]["created"], - "updated": "2024-01-01T12:00:00Z", - } - - self.dummy_libraries[library_index] = updated_library - return Response(updated_library) - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) - else: - return Response({"detail": "Library not found."}, status=status.HTTP_404_NOT_FOUND) + library = get_object_or_404(Library, id=pk) + serializer = LibrarySerializer(library, data=request.data) + + if serializer.is_valid(): + serializer.save() + return Response(serializer.data) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) def partial_update(self, request, pk=None): """ PATCH /libraries/{id}/ Partially update a library. """ - library_index = next((i for i, lib in enumerate(self.dummy_libraries) if lib["id"] == pk), None) - - if library_index is not None: - current_library = self.dummy_libraries[library_index].copy() - serializer = LibrarySerializer(data=request.data, partial=True) - - if serializer.is_valid(): - # Update only the provided fields - for field, value in serializer.validated_data.items(): - current_library[field] = value + library = get_object_or_404(Library, id=pk) + serializer = LibrarySerializer(library, data=request.data, partial=True) - current_library["updated"] = "2024-01-01T12:00:00Z" - self.dummy_libraries[library_index] = current_library - - return Response(current_library) - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) - else: - return Response({"detail": "Library not found."}, status=status.HTTP_404_NOT_FOUND) + if serializer.is_valid(): + serializer.save() + return Response(serializer.data) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) def destroy(self, request, pk=None): """ DELETE /libraries/{id}/ Delete a library. """ - library_index = next((i for i, lib in enumerate(self.dummy_libraries) if lib["id"] == pk), None) - - if library_index is not None: - deleted_library = self.dummy_libraries.pop(library_index) - return Response( - {"detail": f'Library "{deleted_library["title"]}" has been deleted.'}, - status=status.HTTP_204_NO_CONTENT, - ) - else: - return Response({"detail": "Library not found."}, status=status.HTTP_404_NOT_FOUND) + library = get_object_or_404(Library, id=pk) + library_title = library.title + library.delete() + + return Response( + {"detail": f'Library "{library_title}" has been deleted.'}, + status=status.HTTP_204_NO_CONTENT, + ) From f2d57ecabdf67a14795edcdc42f6d29a834954ac Mon Sep 17 00:00:00 2001 From: Bryann Valderrama Date: Wed, 3 Sep 2025 23:51:57 -0500 Subject: [PATCH 17/27] feat: allow all routes to the admin --- openedx_authz/model.conf | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openedx_authz/model.conf b/openedx_authz/model.conf index 459a15fc..067bc87d 100644 --- a/openedx_authz/model.conf +++ b/openedx_authz/model.conf @@ -11,4 +11,4 @@ g = _, _ e = some(where (p.eft == allow)) [matchers] -m = g(r.sub, p.sub) && r.obj == p.obj && regexMatch(r.act, p.act) +m = g(r.sub, p.sub) && (p.obj == '*' || r.obj == p.obj) && (p.act == '*' || regexMatch(r.act, p.act)) From 127d932fcef866e46c0e739121ae07848b289013 Mon Sep 17 00:00:00 2001 From: Bryann Valderrama Date: Wed, 3 Sep 2025 23:52:24 -0500 Subject: [PATCH 18/27] feat: add custom save method --- openedx_authz/models.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/openedx_authz/models.py b/openedx_authz/models.py index 313fe4c2..274ec290 100644 --- a/openedx_authz/models.py +++ b/openedx_authz/models.py @@ -16,6 +16,13 @@ class Library(models.Model): 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" From 868bff46c5306ab25570d2960be3910d0d838ca0 Mon Sep 17 00:00:00 2001 From: Bryann Valderrama Date: Wed, 3 Sep 2025 23:52:38 -0500 Subject: [PATCH 19/27] feat: set id as read only field --- openedx_authz/serializers.py | 1 + 1 file changed, 1 insertion(+) diff --git a/openedx_authz/serializers.py b/openedx_authz/serializers.py index 1e4a722d..62a3cf6e 100644 --- a/openedx_authz/serializers.py +++ b/openedx_authz/serializers.py @@ -20,3 +20,4 @@ class Meta: "title", "description", ] + read_only_fields = ["id"] From 62b30cae31e8269ca15222e6d55d5700248199d0 Mon Sep 17 00:00:00 2001 From: Bryann Valderrama Date: Wed, 3 Sep 2025 23:53:27 -0500 Subject: [PATCH 20/27] refactor: simplify the methods --- openedx_authz/views.py | 51 +++++++++++++++++------------------------- 1 file changed, 20 insertions(+), 31 deletions(-) diff --git a/openedx_authz/views.py b/openedx_authz/views.py index e72f25c7..22206dea 100644 --- a/openedx_authz/views.py +++ b/openedx_authz/views.py @@ -3,9 +3,9 @@ """ from dauthz.core import enforcer +from django.shortcuts import get_object_or_404 from rest_framework import status, viewsets from rest_framework.response import Response -from django.shortcuts import get_object_or_404 from .models import Library from .serializers import LibrarySerializer @@ -22,43 +22,35 @@ def list(self, request): GET /libraries/ Return a list of all libraries. """ - print("\n\nListing libraries...\n\n") - - # Get all libraries from database libraries = Library.objects.all() - - # Add authorization policies for each library - for library in libraries: - enforcer.add_policy( - self.request.user.username, - f"/openedx-authz/api/libraries/{library.id}/", - "(GET)|(POST)|(PUT)|(DELETE)|(PATCH)", - ) - enforcer.save_policy() - - # Filter by organization if requested - org = request.query_params.get("org") - if org: - libraries = libraries.filter(org__iexact=org) - - # Search by title if requested - search = request.query_params.get("search") - if search: - libraries = libraries.filter(title__icontains=search) - - # Serialize the libraries serializer = LibrarySerializer(libraries, many=True) - return Response({"count": libraries.count(), "results": serializer.data}) def create(self, request): """ POST /libraries/ Create a new library. + + Example request body: + + ```json + { + "title": "Title 1", + "org": "org1", + "slug": "slug1", + "description": "Description 1" + } + ``` """ serializer = LibrarySerializer(data=request.data) if serializer.is_valid(): library = serializer.save() + enforcer.add_policy( + self.request.user.username, + f"{self.request.path}{library.id}/", + "(GET)|(PUT)|(DELETE)|(PATCH)", + ) + enforcer.save_policy() return Response(LibrarySerializer(library).data, status=status.HTTP_201_CREATED) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) @@ -68,11 +60,6 @@ def retrieve(self, request, pk=None): Retrieve a specific library by ID. """ library = get_object_or_404(Library, id=pk) - enforcer.remove_policy( - self.request.user.username, f"/openedx-authz/api/libraries/{pk}/", "(GET)|(POST)|(PUT)|(DELETE)|(PATCH)" - ) - enforcer.save_policy() - serializer = LibrarySerializer(library) return Response(serializer.data) @@ -110,6 +97,8 @@ def destroy(self, request, pk=None): library = get_object_or_404(Library, id=pk) library_title = library.title library.delete() + enforcer.remove_filtered_policy(1, self.request.user.username, f"{self.request.path}{library.id}/", "") + enforcer.save_policy() return Response( {"detail": f'Library "{library_title}" has been deleted.'}, From 7bffc1388d8f81f5b4c50ba5ab7ad2c3f059822c Mon Sep 17 00:00:00 2001 From: Bryann Valderrama Date: Thu, 4 Sep 2025 09:02:51 -0500 Subject: [PATCH 21/27] feat: add minimum policies to admin and anonymous users --- openedx_authz/apps.py | 39 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/openedx_authz/apps.py b/openedx_authz/apps.py index 2f7855f7..4070bacf 100644 --- a/openedx_authz/apps.py +++ b/openedx_authz/apps.py @@ -40,3 +40,42 @@ class OpenedxAuthzConfig(AppConfig): }, }, } + + def ready(self): + """ + Add admin users to the authorization policy. + """ + # pylint: disable=import-outside-toplevel + from django.contrib.auth import get_user_model + from dauthz.core import 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) + + enforcer.save_policy() + print("\n\nAdded minimum policies for anonymous users!") + + # 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") + enforcer.save_policy() + + print("Added admin users to the authorization policy!\n\n") From 851a9409f870364e0586045e782ccb3a9fddf723 Mon Sep 17 00:00:00 2001 From: Bryann Valderrama Date: Thu, 4 Sep 2025 09:05:17 -0500 Subject: [PATCH 22/27] feat: add AdminRoleAssignmentViewSet for managing admin role assignments --- openedx_authz/urls.py | 3 ++- openedx_authz/views.py | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/openedx_authz/urls.py b/openedx_authz/urls.py index d03f3f0e..d5997473 100644 --- a/openedx_authz/urls.py +++ b/openedx_authz/urls.py @@ -4,10 +4,11 @@ from django.urls import re_path, include from rest_framework.routers import DefaultRouter -from .views import LibraryViewSet +from .views import LibraryViewSet, AdminRoleAssignmentViewSet router = DefaultRouter() router.register(r"libraries", LibraryViewSet, basename="library") +router.register(r"admin-roles", AdminRoleAssignmentViewSet, basename="admin-roles") urlpatterns = [ re_path(r"^api/", include(router.urls)), diff --git a/openedx_authz/views.py b/openedx_authz/views.py index 22206dea..842d9ce8 100644 --- a/openedx_authz/views.py +++ b/openedx_authz/views.py @@ -104,3 +104,36 @@ def destroy(self, request, pk=None): {"detail": f'Library "{library_title}" has been deleted.'}, status=status.HTTP_204_NO_CONTENT, ) + + +class AdminRoleAssignmentViewSet(viewsets.ViewSet): + """ + ViewSet for managing admin role assignments using Casbin. + """ + + def create(self, request): + """ + POST /admin-roles/ + Assign admin role to a user. + + Example request body: + ```json + { + "username": "john_doe" + } + ``` + """ + username = request.data["username"] + enforcer.add_role_for_user(username, "admin") + enforcer.save_policy() + return Response(f"Admin role assigned to user {username}", status=status.HTTP_201_CREATED) + + def destroy(self, request, pk=None): + """ + DELETE /admin-roles/{username}/ + Remove admin role from a user. + """ + username = pk + enforcer.delete_role_for_user(username, "admin") + enforcer.save_policy() + return Response(f"Admin role removed from user {username}", status=status.HTTP_204_NO_CONTENT) From c5b97a078dc6f36d13735dffe3bd737173c32bbb Mon Sep 17 00:00:00 2001 From: Bryann Valderrama Date: Thu, 4 Sep 2025 12:46:30 -0500 Subject: [PATCH 23/27] feat: add UserPermissionViewSet for managing user-specific permissions --- openedx_authz/urls.py | 3 +- openedx_authz/views.py | 87 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 89 insertions(+), 1 deletion(-) diff --git a/openedx_authz/urls.py b/openedx_authz/urls.py index d5997473..3da6b499 100644 --- a/openedx_authz/urls.py +++ b/openedx_authz/urls.py @@ -4,11 +4,12 @@ from django.urls import re_path, include from rest_framework.routers import DefaultRouter -from .views import LibraryViewSet, AdminRoleAssignmentViewSet +from .views import LibraryViewSet, AdminRoleAssignmentViewSet, UserPermissionViewSet router = DefaultRouter() router.register(r"libraries", LibraryViewSet, basename="library") router.register(r"admin-roles", AdminRoleAssignmentViewSet, basename="admin-roles") +router.register(r"user-permissions", UserPermissionViewSet, basename="user-permissions") urlpatterns = [ re_path(r"^api/", include(router.urls)), diff --git a/openedx_authz/views.py b/openedx_authz/views.py index 842d9ce8..86d51669 100644 --- a/openedx_authz/views.py +++ b/openedx_authz/views.py @@ -137,3 +137,90 @@ def destroy(self, request, pk=None): enforcer.delete_role_for_user(username, "admin") enforcer.save_policy() return Response(f"Admin role removed from user {username}", status=status.HTTP_204_NO_CONTENT) + + +class UserPermissionViewSet(viewsets.ViewSet): + """ + ViewSet for managing specific user permissions using Casbin. + Allows adding or removing specific permissions for users on resources. + + Example: + ```json + { + "username": "john_doe", + "obj": "/api/libraries/", + "act": "GET" + } + ``` + """ + + def create(self, request): + """ + POST /user-permissions/ + Add a specific permission to a user. + + Example request body: + ```json + { + "username": "john_doe", + "obj": "/api/libraries/123/", + "act": "GET" + } + ``` + """ + username = request.data.get("username") + obj = request.data.get("obj") + act = request.data.get("act") + + if not all([username, obj, act]): + return Response({"error": "username, obj, and act are required fields"}, status=status.HTTP_400_BAD_REQUEST) + + enforcer.add_policy(username, obj, act) + enforcer.save_policy() + + return Response( + { + "message": f"Permission '{act}' on '{obj}' granted to user '{username}'", + "username": username, + "obj": obj, + "act": act, + }, + status=status.HTTP_201_CREATED, + ) + + def destroy(self, request, pk=None): + """ + DELETE /user-permissions/{username}/ + Remove a specific permission from a user. + + Query parameters: + - obj: The resource path (required) + - act: The action/method (required) + + Example: DELETE /user-permissions/john_doe/?obj=/api/libraries/123/&act=GET + """ + username = pk + obj = request.query_params.get("obj") + act = request.query_params.get("act") + + if not all([obj, act]): + return Response({"error": "obj and act query parameters are required"}, status=status.HTTP_400_BAD_REQUEST) + + result = enforcer.remove_policy(username, obj, act) + enforcer.save_policy() + + if result: + return Response( + { + "message": f"Permission '{act}' on '{obj}' removed from user '{username}'", + "username": username, + "obj": obj, + "act": act, + }, + status=status.HTTP_204_NO_CONTENT, + ) + else: + return Response( + {"error": f"Permission not found for user '{username}' on '{obj}' with action '{act}'"}, + status=status.HTTP_404_NOT_FOUND, + ) From 0dce5adaa89b27354377a7217dc5dec4375bb72e Mon Sep 17 00:00:00 2001 From: Bryann Valderrama Date: Fri, 5 Sep 2025 16:23:51 -0500 Subject: [PATCH 24/27] refactor: use auto save in the enforcer --- openedx_authz/apps.py | 4 ++-- openedx_authz/views.py | 9 +++------ 2 files changed, 5 insertions(+), 8 deletions(-) diff --git a/openedx_authz/apps.py b/openedx_authz/apps.py index 4070bacf..846c08d7 100644 --- a/openedx_authz/apps.py +++ b/openedx_authz/apps.py @@ -49,6 +49,8 @@ def ready(self): from django.contrib.auth import get_user_model from dauthz.core import enforcer + enforcer.enable_auto_save(True) + # Add minimum policies for anonymous users anonymous_policies = [ ("/", "*"), @@ -66,7 +68,6 @@ def ready(self): if not enforcer.has_policy("anonymous", resource, action): enforcer.add_policy("anonymous", resource, action) - enforcer.save_policy() print("\n\nAdded minimum policies for anonymous users!") # Ensure admin users have access to all resources @@ -76,6 +77,5 @@ def ready(self): admin_users = User.objects.filter(is_staff=True, is_superuser=True) for user in admin_users: enforcer.add_role_for_user(user.username, "admin") - enforcer.save_policy() print("Added admin users to the authorization policy!\n\n") diff --git a/openedx_authz/views.py b/openedx_authz/views.py index 86d51669..a8230377 100644 --- a/openedx_authz/views.py +++ b/openedx_authz/views.py @@ -10,6 +10,8 @@ from .models import Library from .serializers import LibrarySerializer +enforcer.enable_auto_save(True) + class LibraryViewSet(viewsets.ViewSet): """ @@ -50,7 +52,6 @@ def create(self, request): f"{self.request.path}{library.id}/", "(GET)|(PUT)|(DELETE)|(PATCH)", ) - enforcer.save_policy() return Response(LibrarySerializer(library).data, status=status.HTTP_201_CREATED) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) @@ -98,7 +99,6 @@ def destroy(self, request, pk=None): library_title = library.title library.delete() enforcer.remove_filtered_policy(1, self.request.user.username, f"{self.request.path}{library.id}/", "") - enforcer.save_policy() return Response( {"detail": f'Library "{library_title}" has been deleted.'}, @@ -123,9 +123,9 @@ def create(self, request): } ``` """ + enforcer.enable_auto_save(True) username = request.data["username"] enforcer.add_role_for_user(username, "admin") - enforcer.save_policy() return Response(f"Admin role assigned to user {username}", status=status.HTTP_201_CREATED) def destroy(self, request, pk=None): @@ -135,7 +135,6 @@ def destroy(self, request, pk=None): """ username = pk enforcer.delete_role_for_user(username, "admin") - enforcer.save_policy() return Response(f"Admin role removed from user {username}", status=status.HTTP_204_NO_CONTENT) @@ -176,7 +175,6 @@ def create(self, request): return Response({"error": "username, obj, and act are required fields"}, status=status.HTTP_400_BAD_REQUEST) enforcer.add_policy(username, obj, act) - enforcer.save_policy() return Response( { @@ -207,7 +205,6 @@ def destroy(self, request, pk=None): return Response({"error": "obj and act query parameters are required"}, status=status.HTTP_400_BAD_REQUEST) result = enforcer.remove_policy(username, obj, act) - enforcer.save_policy() if result: return Response( From 0c2fbf3e0b7bda7e895e7a4f0e9a912cfdccff89 Mon Sep 17 00:00:00 2001 From: Bryann Valderrama Date: Mon, 8 Sep 2025 15:23:48 -0500 Subject: [PATCH 25/27] feat: add PolicyBulkViewSet and PolicySingleViewSet for managing bulk and single policy operations --- openedx_authz/urls.py | 8 ++- openedx_authz/views.py | 160 +++++++++++++++++++++++++---------------- 2 files changed, 104 insertions(+), 64 deletions(-) diff --git a/openedx_authz/urls.py b/openedx_authz/urls.py index 3da6b499..dfd803b9 100644 --- a/openedx_authz/urls.py +++ b/openedx_authz/urls.py @@ -2,14 +2,16 @@ URLs for openedx_authz. """ -from django.urls import re_path, include +from django.urls import include, re_path from rest_framework.routers import DefaultRouter -from .views import LibraryViewSet, AdminRoleAssignmentViewSet, UserPermissionViewSet + +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"user-permissions", UserPermissionViewSet, basename="user-permissions") +router.register(r"policy-bulk", PolicyBulkViewSet, basename="policy-bulk") +router.register(r"policy-single", PolicySingleViewSet, basename="policy-single") urlpatterns = [ re_path(r"^api/", include(router.urls)), diff --git a/openedx_authz/views.py b/openedx_authz/views.py index a8230377..aa8c59fd 100644 --- a/openedx_authz/views.py +++ b/openedx_authz/views.py @@ -123,7 +123,6 @@ def create(self, request): } ``` """ - enforcer.enable_auto_save(True) username = request.data["username"] enforcer.add_role_for_user(username, "admin") return Response(f"Admin role assigned to user {username}", status=status.HTTP_201_CREATED) @@ -138,86 +137,125 @@ def destroy(self, request, pk=None): return Response(f"Admin role removed from user {username}", status=status.HTTP_204_NO_CONTENT) -class UserPermissionViewSet(viewsets.ViewSet): +class PolicyBulkViewSet(viewsets.ViewSet): """ - ViewSet for managing specific user permissions using Casbin. - Allows adding or removing specific permissions for users on resources. - - Example: - ```json - { - "username": "john_doe", - "obj": "/api/libraries/", - "act": "GET" - } - ``` + ViewSet for bulk policy operations using Casbin's add_policies and remove_policies. + This is a simple testing interface for bulk policy management. """ def create(self, request): """ - POST /user-permissions/ - Add a specific permission to a user. + POST /policy-bulk/ + Add multiple policies at once using add_policies. Example request body: ```json { - "username": "john_doe", - "obj": "/api/libraries/123/", - "act": "GET" + "policies": [ + ["user1", "/api/resource1/", "GET"], + ["user2", "/api/resource2/", "POST"], + ["user3", "/api/resource3/", "DELETE"] + ] } ``` """ - username = request.data.get("username") - obj = request.data.get("obj") - act = request.data.get("act") - - if not all([username, obj, act]): - return Response({"error": "username, obj, and act are required fields"}, status=status.HTTP_400_BAD_REQUEST) + policies = request.data.get("policies", []) + result = enforcer.add_policies(policies) + return Response( + { + "message": "Bulk policy addition completed", + "success": result, + "policies_added": len(policies), + "policies": policies, + }, + status=status.HTTP_201_CREATED if result else status.HTTP_200_OK, + ) - enforcer.add_policy(username, obj, act) + def destroy(self, request, pk=None): # pylint: disable=unused-argument + """ + DELETE /policy-bulk/remove/ + Remove multiple policies at once using remove_policies. + Uses request body instead of pk since we need to pass multiple policies. + Example request body: + ```json + { + "policies": [ + ["user1", "/api/resource1/", "GET"], + ["user2", "/api/resource2/", "POST"] + ] + } + ``` + """ + policies = request.data.get("policies", []) + result = enforcer.remove_policies(policies) return Response( { - "message": f"Permission '{act}' on '{obj}' granted to user '{username}'", - "username": username, - "obj": obj, - "act": act, + "message": "Bulk policy removal completed", + "success": result, + "policies_removed": len(policies), + "policies": policies, }, - status=status.HTTP_201_CREATED, + status=status.HTTP_204_NO_CONTENT if result else status.HTTP_200_OK, ) - def destroy(self, request, pk=None): + +class PolicySingleViewSet(viewsets.ViewSet): + """ + ViewSet for single policy operations using Casbin's add_policy and remove_policy. + Simple testing interface for individual policy management. + """ + + def create(self, request): + """ + POST /policy-single/ + Add a single policy using add_policy. + + Example request body: + ```json + { + "subject": "user1", + "object": "/api/resource1/", + "action": "GET" + } + ``` """ - DELETE /user-permissions/{username}/ - Remove a specific permission from a user. + subject = request.data.get("subject") + obj = request.data.get("object") + action = request.data.get("action") + result = enforcer.add_policy(subject, obj, action) + return Response( + { + "message": "Policy added successfully" if result else "Policy already exists", + "success": result, + "policy": [subject, obj, action], + }, + status=status.HTTP_201_CREATED if result else status.HTTP_200_OK, + ) - Query parameters: - - obj: The resource path (required) - - act: The action/method (required) + def destroy(self, request, pk=None): # pylint: disable=unused-argument + """ + DELETE /policy-single/remove/ + Remove a single policy using remove_policy. - Example: DELETE /user-permissions/john_doe/?obj=/api/libraries/123/&act=GET + Example request body: + ```json + { + "subject": "user1", + "object": "/api/resource1/", + "action": "GET" + } + ``` """ - username = pk - obj = request.query_params.get("obj") - act = request.query_params.get("act") - - if not all([obj, act]): - return Response({"error": "obj and act query parameters are required"}, status=status.HTTP_400_BAD_REQUEST) - - result = enforcer.remove_policy(username, obj, act) - - if result: - return Response( - { - "message": f"Permission '{act}' on '{obj}' removed from user '{username}'", - "username": username, - "obj": obj, - "act": act, - }, - status=status.HTTP_204_NO_CONTENT, - ) - else: - return Response( - {"error": f"Permission not found for user '{username}' on '{obj}' with action '{act}'"}, - status=status.HTTP_404_NOT_FOUND, - ) + subject = request.data.get("subject") + obj = request.data.get("object") + action = request.data.get("action") + result = enforcer.remove_policy(subject, obj, action) + return Response( + { + "message": "Policy removed successfully" if result else "Policy not found", + "success": result, + "policy": [subject, obj, action], + }, + status=status.HTTP_204_NO_CONTENT if result else status.HTTP_404_NOT_FOUND, + ) From 907027b86032850c8175f288d169f9491f198878 Mon Sep 17 00:00:00 2001 From: Bryann Valderrama Date: Mon, 8 Sep 2025 17:20:16 -0500 Subject: [PATCH 26/27] chore: add redis-watcher in base requirements --- openedx_authz/views.py | 3 +-- requirements/base.in | 1 + requirements/base.txt | 8 +++++++- requirements/dev.txt | 7 +++++++ requirements/doc.txt | 7 +++++++ requirements/quality.txt | 7 +++++++ requirements/test.txt | 7 +++++++ 7 files changed, 37 insertions(+), 3 deletions(-) diff --git a/openedx_authz/views.py b/openedx_authz/views.py index aa8c59fd..d0e948ae 100644 --- a/openedx_authz/views.py +++ b/openedx_authz/views.py @@ -96,12 +96,11 @@ def destroy(self, request, pk=None): Delete a library. """ library = get_object_or_404(Library, id=pk) - library_title = library.title library.delete() enforcer.remove_filtered_policy(1, self.request.user.username, f"{self.request.path}{library.id}/", "") return Response( - {"detail": f'Library "{library_title}" has been deleted.'}, + {"detail": f'Library "{library}" has been deleted.'}, status=status.HTTP_204_NO_CONTENT, ) diff --git a/requirements/base.in b/requirements/base.in index d5e06ccb..d484b1b0 100644 --- a/requirements/base.in +++ b/requirements/base.in @@ -8,3 +8,4 @@ djangorestframework # Django REST framework for API development openedx-atlas casbin-django-orm-adapter # Casbin Django ORM adapter django_authorization # Django Authorization library +redis-watcher # Redis Watcher for Casbin diff --git a/requirements/base.txt b/requirements/base.txt index 027f714b..73bf18eb 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -24,7 +24,13 @@ djangorestframework==3.16.1 openedx-atlas==0.7.0 # via -r requirements/base.in pycasbin==2.2.0 - # via casbin-django-orm-adapter + # via + # casbin-django-orm-adapter + # redis-watcher +redis==6.4.0 + # via redis-watcher +redis-watcher==1.8.0 + # via -r requirements/base.in simpleeval==1.0.3 # via # casbin diff --git a/requirements/dev.txt b/requirements/dev.txt index 48f814f1..a4f13092 100644 --- a/requirements/dev.txt +++ b/requirements/dev.txt @@ -150,6 +150,7 @@ pycasbin==2.2.0 # via # -r requirements/quality.txt # casbin-django-orm-adapter + # redis-watcher pycodestyle==2.14.0 # via -r requirements/quality.txt pydocstyle==6.3.0 @@ -206,6 +207,12 @@ pyyaml==6.0.2 # -r requirements/quality.txt # code-annotations # edx-i18n-tools +redis==6.4.0 + # via + # -r requirements/quality.txt + # redis-watcher +redis-watcher==1.8.0 + # via -r requirements/quality.txt simpleeval==1.0.3 # via # -r requirements/quality.txt diff --git a/requirements/doc.txt b/requirements/doc.txt index f8423260..76db7399 100644 --- a/requirements/doc.txt +++ b/requirements/doc.txt @@ -128,6 +128,7 @@ pycasbin==2.2.0 # via # -r requirements/test.txt # casbin-django-orm-adapter + # redis-watcher pycparser==2.22 # via cffi pydata-sphinx-theme==0.15.4 @@ -163,6 +164,12 @@ pyyaml==6.0.2 # code-annotations readme-renderer==44.0 # via twine +redis==6.4.0 + # via + # -r requirements/test.txt + # redis-watcher +redis-watcher==1.8.0 + # via -r requirements/test.txt requests==2.32.5 # via # id diff --git a/requirements/quality.txt b/requirements/quality.txt index 0048dbf3..b408955f 100644 --- a/requirements/quality.txt +++ b/requirements/quality.txt @@ -84,6 +84,7 @@ pycasbin==2.2.0 # via # -r requirements/test.txt # casbin-django-orm-adapter + # redis-watcher pycodestyle==2.14.0 # via -r requirements/quality.in pydocstyle==6.3.0 @@ -123,6 +124,12 @@ pyyaml==6.0.2 # via # -r requirements/test.txt # code-annotations +redis==6.4.0 + # via + # -r requirements/test.txt + # redis-watcher +redis-watcher==1.8.0 + # via -r requirements/test.txt simpleeval==1.0.3 # via # -r requirements/test.txt diff --git a/requirements/test.txt b/requirements/test.txt index 2f0e2551..e4c77010 100644 --- a/requirements/test.txt +++ b/requirements/test.txt @@ -48,6 +48,7 @@ pycasbin==2.2.0 # via # -r requirements/base.txt # casbin-django-orm-adapter + # redis-watcher pygments==2.19.2 # via pytest pytest==8.4.1 @@ -62,6 +63,12 @@ python-slugify==8.0.4 # via code-annotations pyyaml==6.0.2 # via code-annotations +redis==6.4.0 + # via + # -r requirements/base.txt + # redis-watcher +redis-watcher==1.8.0 + # via -r requirements/base.txt simpleeval==1.0.3 # via # -r requirements/base.txt From e9165edc5ee17b3f7455308660f825fa641f65da Mon Sep 17 00:00:00 2001 From: Bryann Valderrama Date: Tue, 9 Sep 2025 09:05:09 -0500 Subject: [PATCH 27/27] refactor: replace enforcer import with custom enforcer --- openedx_authz/apps.py | 9 ++++----- openedx_authz/custom_enforcer.py | 27 +++++++++++++++++++++++++++ openedx_authz/views.py | 5 +++-- 3 files changed, 34 insertions(+), 7 deletions(-) create mode 100644 openedx_authz/custom_enforcer.py diff --git a/openedx_authz/apps.py b/openedx_authz/apps.py index 846c08d7..8f2154b4 100644 --- a/openedx_authz/apps.py +++ b/openedx_authz/apps.py @@ -47,9 +47,10 @@ def ready(self): """ # pylint: disable=import-outside-toplevel from django.contrib.auth import get_user_model - from dauthz.core import enforcer - enforcer.enable_auto_save(True) + from openedx_authz.custom_enforcer import get_enforcer + + enforcer = get_enforcer() # Add minimum policies for anonymous users anonymous_policies = [ @@ -68,8 +69,6 @@ def ready(self): if not enforcer.has_policy("anonymous", resource, action): enforcer.add_policy("anonymous", resource, action) - print("\n\nAdded minimum policies for anonymous users!") - # Ensure admin users have access to all resources User = get_user_model() @@ -78,4 +77,4 @@ def ready(self): for user in admin_users: enforcer.add_role_for_user(user.username, "admin") - print("Added admin users to the authorization policy!\n\n") + print("\n\nAdded default policies!\n\n") diff --git a/openedx_authz/custom_enforcer.py b/openedx_authz/custom_enforcer.py new file mode 100644 index 00000000..c3b8fc46 --- /dev/null +++ b/openedx_authz/custom_enforcer.py @@ -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(): + """ + 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 diff --git a/openedx_authz/views.py b/openedx_authz/views.py index d0e948ae..fd2a808b 100644 --- a/openedx_authz/views.py +++ b/openedx_authz/views.py @@ -2,15 +2,16 @@ Views for openedx_authz DRF API. """ -from dauthz.core import enforcer from django.shortcuts import get_object_or_404 from rest_framework import status, viewsets from rest_framework.response import Response +from openedx_authz.custom_enforcer import get_enforcer + from .models import Library from .serializers import LibrarySerializer -enforcer.enable_auto_save(True) +enforcer = get_enforcer() class LibraryViewSet(viewsets.ViewSet):