[FC-0099] feat: add casbin-based authorization engine #55
[FC-0099] feat: add casbin-based authorization engine #55mariajgrimaldi merged 19 commits intoopenedx:mainfrom
Conversation
|
Thanks for the pull request, @BryanttV! This repository is currently maintained by 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 approvalIf you haven't already, check this list to see if your contribution needs to go through the product review process.
🔘 Provide contextTo 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:
🔘 Get a green buildIf one or more checks are failing, continue working on your changes until this is no longer the case and your build turns green. DetailsWhere 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:
💡 As a result it may take up to several weeks or months to complete a review and merge your PR. |
78e92b5 to
bdd543e
Compare
| Should have attributes like ptype, v0, v1, etc. with lists | ||
| of values to filter by. | ||
| """ | ||
| queryset = CasbinRule.objects.using(self.db_alias) |
There was a problem hiding this comment.
[critical] Do we have to do this each time we load a filtered policy? Can we do it once during initialization?
openedx_authz/engine/enforcer.py
Outdated
| logger = logging.getLogger(__name__) | ||
|
|
||
| adapter = ExtendedAdapter() | ||
| enforcer = FastEnforcer(settings.CASBIN_MODEL, adapter, enable_log=False) |
There was a problem hiding this comment.
[non-critical] Why are we setting enable_log=False?
There was a problem hiding this comment.
Logging is supposed to be disabled by default, so we don't need to do it explicitly.
There was a problem hiding this comment.
[non-critical] Not sure I follow, what don't we need to do explicitly?
There was a problem hiding this comment.
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)
There was a problem hiding this comment.
It makes sense to have it on, no?
There was a problem hiding this comment.
Yes, although I’m not sure how much noise it could cause once there are many enforcements. But we could enable it.
There was a problem hiding this comment.
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) |
There was a problem hiding this comment.
[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!
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
@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 |
There was a problem hiding this comment.
[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:
- We originally said we shouldn't hit the database on every enforce, but this might still be acceptable for the MVP.
- 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_permissionhelper we're adding inapi/*.
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
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
@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:
- 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. - About the
FastEnforcercache, 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 thecache_key_orderargument), it’s falling back to the standard enforcer of Casbin.
| Args: | ||
| event: The policy change event from Redis | ||
| """ | ||
| logger.info(f"Policy change event received: {event}") |
There was a problem hiding this comment.
[critical] So if we use cache for the policies as I mentioned here, we could invalidate them here?
openedx_authz/settings/common.py
Outdated
|
|
||
| # 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 |
There was a problem hiding this comment.
| # 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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
Please let's address this here: #73, if we're going to use redis we might need to solve this somehow
|
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! |
| The configured watcher instance | ||
| """ | ||
| watcher_options = WatcherOptions() | ||
| watcher_options.host = settings.REDIS_HOST |
There was a problem hiding this comment.
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
78cff82 to
c4ec72b
Compare
|
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! |
fa3f568 to
2a9099d
Compare
|
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
left a comment
There was a problem hiding this comment.
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!
Description
This PR introduces the Casbin engine integration for Open edX. It includes:
FastEnforcerwith DB adapter and Redis watcher for distributed policy evaluation.Additional Components
Supporting Information