Skip to content

[FC-0099] feat: add casbin-based authorization engine #55

Merged
mariajgrimaldi merged 19 commits intoopenedx:mainfrom
eduNEXT:bav/casbin-engine
Sep 30, 2025
Merged

[FC-0099] feat: add casbin-based authorization engine #55
mariajgrimaldi merged 19 commits intoopenedx:mainfrom
eduNEXT:bav/casbin-engine

Conversation

@BryanttV
Copy link
Contributor

@BryanttV BryanttV commented Sep 16, 2025

Description

This PR introduces the Casbin engine integration for Open edX. It includes:

  • Enforcer: Main entry point using FastEnforcer with DB adapter and Redis watcher for distributed policy evaluation.
  • Adapter: Extended Django ORM adapter with filtering for efficient and selective policy loading.
  • Watcher: Redis-based synchronization to propagate policy changes across instances.

Additional Components

  • Filter: Utility class to load specific subsets of policies by type, subject, action, or scope.

Supporting Information

@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 16, 2025
@openedx-webhooks
Copy link

openedx-webhooks commented Sep 16, 2025

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.

Details
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.

@github-project-automation github-project-automation bot moved this to Needs Triage in Contributions Sep 16, 2025
@BryanttV BryanttV changed the title Bav/casbin engine feat: add casbin engine Sep 16, 2025
@mphilbrick211 mphilbrick211 moved this from Needs Triage to Waiting on Author in Contributions Sep 16, 2025
@mariajgrimaldi mariajgrimaldi changed the title feat: add casbin engine [FC-0099] feat: add casbin engine Sep 17, 2025
@BryanttV BryanttV marked this pull request as ready for review September 18, 2025 15:47
@mariajgrimaldi mariajgrimaldi changed the title [FC-0099] feat: add casbin engine [FC-0099] feat: add casbin-based authorization engine Sep 18, 2025
Should have attributes like ptype, v0, v1, etc. with lists
of values to filter by.
"""
queryset = CasbinRule.objects.using(self.db_alias)
Copy link
Member

@mariajgrimaldi mariajgrimaldi Sep 19, 2025

Choose a reason for hiding this comment

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

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

logger = logging.getLogger(__name__)

adapter = ExtendedAdapter()
enforcer = FastEnforcer(settings.CASBIN_MODEL, adapter, enable_log=False)
Copy link
Member

@mariajgrimaldi mariajgrimaldi Sep 19, 2025

Choose a reason for hiding this comment

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

[non-critical] Why are we setting enable_log=False?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Logging is supposed to be disabled by default, so we don't need to do it explicitly.

Copy link
Member

@mariajgrimaldi mariajgrimaldi Sep 22, 2025

Choose a reason for hiding this comment

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

[non-critical] Not sure I follow, what don't we need to do explicitly?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

If we have logging enabled, each time we perform an enforce a log with the result will be shown. https://casbin.org/docs/log-error/#logging. However, it’s disabled by default, so there’s no need to pass the argument (unless we want to enable it)

Copy link
Member

Choose a reason for hiding this comment

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

It makes sense to have it on, no?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yes, although I’m not sure how much noise it could cause once there are many enforcements. But we could enable it.

Copy link
Contributor

Choose a reason for hiding this comment

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

Having a log of enforcement actions is one of the requirements. I'd rather have it in a separate file than the tracking logs if possible due to volume, though, which it looks like it supports.

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

@mariajgrimaldi mariajgrimaldi Sep 22, 2025

Choose a reason for hiding this comment

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

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

Copy link
Contributor Author

Choose a reason for hiding this comment

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

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

It’s done the same way in the SQLAlchemy adapter

Copy link
Member

@mariajgrimaldi mariajgrimaldi Sep 25, 2025

Choose a reason for hiding this comment

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

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

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

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

Copy link
Member

@mariajgrimaldi mariajgrimaldi Sep 25, 2025

Choose a reason for hiding this comment

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

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

Copy link
Contributor Author

Choose a reason for hiding this comment

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

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

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

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

Copy link
Member

@mariajgrimaldi mariajgrimaldi Sep 26, 2025

Choose a reason for hiding this comment

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

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

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

"""
return True

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

@mariajgrimaldi mariajgrimaldi Sep 22, 2025

Choose a reason for hiding this comment

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

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

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

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

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

Copy link
Member

Choose a reason for hiding this comment

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

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

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

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

Do you think this makes sense?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

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

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

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

Copy link
Member

Choose a reason for hiding this comment

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

Opened issues to address this:
#73
#72

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

@mariajgrimaldi mariajgrimaldi Sep 22, 2025

Choose a reason for hiding this comment

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

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


# Add Casbin configuration
settings.CASBIN_MODEL = os.path.join(ROOT_DIRECTORY, "engine", "config", "model.conf")
# Redis host and port are temporarily loaded here for the MVP
Copy link
Member

@mariajgrimaldi mariajgrimaldi Sep 22, 2025

Choose a reason for hiding this comment

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

Suggested change
# Redis host and port are temporarily loaded here for the MVP
# TODO: Replace with a more dynamic configuration... Redis host and port are temporarily loaded here for the MVP

[critical] Also can we load it from the CACHE configuration? Some people might change the redis host for their installation, but if they do, they should also change that setting - that's the setting I know, not sure if there's a different one to configure redis.

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 updated the comment to include your suggestion.

I checked that setting, but I'm not sure it will work for us. I think we can leave it as it is for now until more general connection variables are defined, as @bmtcril mentioned.

Copy link
Member

@mariajgrimaldi mariajgrimaldi Sep 30, 2025

Choose a reason for hiding this comment

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

Please let's address this here: #73, if we're going to use redis we might need to solve this somehow

@mariajgrimaldi
Copy link
Member

I think my review is done for now. Sorry it wasn't continuous. If you address the critical comments and also check the non-critical ones I labeled, we can move forward with the rest of the PRs. Thanks a lot for this, it's looking good!

@mariajgrimaldi mariajgrimaldi added the FC Relates to an Axim Funded Contribution project label Sep 22, 2025
The configured watcher instance
"""
watcher_options = WatcherOptions()
watcher_options.host = settings.REDIS_HOST
Copy link
Contributor

Choose a reason for hiding this comment

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

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

@MaferMazu MaferMazu linked an issue Sep 22, 2025 that may be closed by this pull request
@bmtcril
Copy link
Contributor

bmtcril commented Sep 29, 2025

I've been through this and done some local testing on the branch, I agree with @mariajgrimaldi that we can move forward with this after the critical suggestions are added and tests pass. Thanks for all of the hard work on this!

@BryanttV
Copy link
Contributor Author

Hi everyone, the PR has been updated with the latest changes. The idea is to address the documentation and coverage checks in another PR.
@mariajgrimaldi @bmtcril

Copy link
Member

@mariajgrimaldi mariajgrimaldi left a comment

Choose a reason for hiding this comment

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

I've been using this implementation while working on the public API for the framework, and it's working well. Thanks a lot!

I'd suggest not focusing on the coverage or docs failures right now. Instead, let's open new issues so we can handle those later, since we have more immediate issues to address. Thanks!

@mariajgrimaldi mariajgrimaldi merged commit c914c28 into openedx:main Sep 30, 2025
12 of 14 checks passed
@github-project-automation github-project-automation bot moved this from Waiting on Author to Done in Contributions Sep 30, 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). FC Relates to an Axim Funded Contribution project 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.

Build engine utilities for the Casbin-based authorization engine

5 participants