Skip to content

[DO NOT MERGE] feat: basic casbin tests#37

Closed
BryanttV wants to merge 27 commits intoopenedx:mainfrom
eduNEXT:bav/casbin-basic-testing
Closed

[DO NOT MERGE] feat: basic casbin tests#37
BryanttV wants to merge 27 commits intoopenedx:mainfrom
eduNEXT:bav/casbin-basic-testing

Conversation

@BryanttV
Copy link
Contributor

@BryanttV BryanttV commented Sep 4, 2025

Description

DO NOT MERGE. Only some basic tests using Casbin.

Endpoints

  • /openedx-authz/api/libraries/: List all Libraries (according to a custom model). Initially, only the superadmin users have permissions.
  • /openedx-authz/api/admin-roles/: Add or remove users to grant access to all resources.
  • /openedx-authz/api/policy-single/: Add or remove specific permissions to users in a specific resource.

How to test

Using Tutor:

  1. Log in with an admin user. IMPORTANT!
  2. Install this plugin.
  3. Run migrations to create the new Library table and the casbin_rule table.
  4. Test the endpoints.
    • Library CRUD > {lms-domain}/openedx-authz/api/libraries/: List, create, update, or delete a Library.
    • Admin Role Assignment > {lms-domain}/openedx-authz/api/admin-roles/: The user has access to all endpoints.
    • Policy Bulk Assignment> {lms-domain}/openedx-authz/api/policy-bulk/ (Currently, it doesn't work)
    • Policy Single Assignment > {lms-domain}/openedx-authz/api/policy-single/: The user has access to a specific endpoint.

Demo

casbin-testing.mp4

@openedx-webhooks openedx-webhooks added open-source-contribution PR author is not from Axim or 2U core contributor PR author is a Core Contributor (who may or may not have write access to this repo). labels Sep 4, 2025
@openedx-webhooks
Copy link

Thanks for the pull request, @BryanttV!

This repository is currently maintained by @openedx/committers-openedx-authz.

Once you've gone through the following steps feel free to tag them in a comment and let them know that your changes are ready for engineering review.

🔘 Get product approval

If you haven't already, check this list to see if your contribution needs to go through the product review process.

  • If it does, you'll need to submit a product proposal for your contribution, and have it reviewed by the Product Working Group.
    • This process (including the steps you'll need to take) is documented here.
  • If it doesn't, simply proceed with the next step.
🔘 Provide context

To help your reviewers and other members of the community understand the purpose and larger context of your changes, feel free to add as much of the following information to the PR description as you can:

  • Dependencies

    This PR must be merged before / after / at the same time as ...

  • Blockers

    This PR is waiting for OEP-1234 to be accepted.

  • Timeline information

    This PR must be merged by XX date because ...

  • Partner information

    This is for a course on edx.org.

  • Supporting documentation
  • Relevant Open edX discussion forum threads
🔘 Get a green build

If one or more checks are failing, continue working on your changes until this is no longer the case and your build turns green.

🔘 Update the status of your PR

Your PR is currently marked as a draft. After completing the steps above, update its status by clicking "Ready for Review", or removing "WIP" from the title, as appropriate.


Where can I find more information?

If you'd like to get more details on all aspects of the review process for open source pull requests (OSPRs), check out the following resources:

When can I expect my changes to be merged?

Our goal is to get community contributions seen and reviewed as efficiently as possible.

However, the amount of time that it takes to review and merge a PR can vary significantly based on factors such as:

  • The size and impact of the changes that it introduces
  • The need for product review
  • Maintenance status of the parent repository

💡 As a result it may take up to several weeks or months to complete a review and merge your PR.

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)

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!

from .models import Library
from .serializers import LibrarySerializer

enforcer = 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.

Echo to my previous question: do we need an instance of the enforcer here? Does this mean, 1 instance 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.

In my last tests importing from casbin_adapter.enforcer import enforcer we can use it with the custom Casbin configuration in the settings.py

Comment on lines +51 to +55
enforcer.add_policy(
self.request.user.username,
f"{self.request.path}{library.id}/",
"(GET)|(PUT)|(DELETE)|(PATCH)",
)
Copy link
Member

Choose a reason for hiding this comment

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

I think these kind of operations should be decoupled from the application code, like event-based. What do you think?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

For this PoC I added it inline to show how policies could be created, but I agree that in a more complete design we should decouple it. Do you have any idea using an event-based approach?

Copy link
Member

Choose a reason for hiding this comment

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

Instead of doing it in-line, we could hear the library creation event and react to it to create the policy, although not sure how sustainable this would be long term since we'd have to do this for all object lifecycle.

"""
library = get_object_or_404(Library, id=pk)
library.delete()
enforcer.remove_filtered_policy(1, self.request.user.username, f"{self.request.path}{library.id}/", "")
Copy link
Member

Choose a reason for hiding this comment

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

In the architecture proposal I'm working on we're proposing to have an intermediate table which links policies to actual objects like libraries through a FK. When the object is removed then we should also remove the policy. With what you know do you think this setup makes sense?

Comment on lines +9 to +11
casbin-django-orm-adapter # Casbin Django ORM adapter
django_authorization # Django Authorization library
redis-watcher # Redis Watcher for Casbin
Copy link
Member

Choose a reason for hiding this comment

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

I'd like the dependency graph of this so we understand what each library is used for.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

With the changes I have made, the current requirements are:

  • casbin: Core authorization engine. Evaluates requests against model + policy.
  • casbin-django-orm-adapter: Persists Casbin policies using Django ORM. Bridges Casbin with our database.
  • redis-watcher: Keeps enforcers in sync across Django processes/workers by broadcasting policy changes through Redis.

The django-authorization library was removed. This library was used to integrates Casbin with Django by adding middleware and decorators for the views that automatically enforce Casbin policies during request handling.

Copy link
Member

Choose a reason for hiding this comment

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

What I liked about django-authorization is that includes some useful commands: https://github.com/pycasbin/django-authorization/tree/master/dauthz/management/commands, I think we should also extend those to implement a "validate" for our rules. What do you think?

@BryanttV
Copy link
Contributor Author

The implementation of the Casbin model continued in this PR: #49

@BryanttV BryanttV closed this Sep 25, 2025
@github-project-automation github-project-automation bot moved this from Waiting on Author to Done in Contributions Sep 25, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

core contributor PR author is a Core Contributor (who may or may not have write access to this repo). open-source-contribution PR author is not from Axim or 2U

Projects

Archived in project

Development

Successfully merging this pull request may close these issues.

3 participants