-
Notifications
You must be signed in to change notification settings - Fork 5
[FC-0099] feat: add casbin-based authorization engine #55
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
1205cd0
61eb732
75090c9
61eec0c
c7a917d
b83c5eb
2ab5633
2a9099d
e868e5b
5ad3481
87baeb6
86eb81a
e632bcc
a266591
67a417a
ab37d4d
fffdecf
311a93f
8971bc5
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,123 @@ | ||
| """ | ||
| Extended Casbin Adapter with Filtering Support. | ||
|
|
||
| This module provides an enhanced adapter implementation for Casbin that extends | ||
| the base Django adapter with filtering capabilities. The ExtendedAdapter allows | ||
| for efficient loading of policy rules from the database with support for | ||
| filtering based on policy attributes. | ||
|
|
||
| The adapter combines functionality from both the base Adapter (for Django ORM | ||
| integration) and FilteredAdapter (for selective policy loading) to provide | ||
| optimized policy management for authorization systems. | ||
| """ | ||
|
|
||
| from enum import Enum | ||
|
|
||
| from casbin import persist | ||
| from casbin.model import Model | ||
| from casbin.persist import FilteredAdapter | ||
| from casbin_adapter.adapter import Adapter | ||
| from casbin_adapter.models import CasbinRule | ||
| from django.db.models import QuerySet | ||
|
|
||
| from openedx_authz.engine.filter import Filter | ||
|
|
||
|
|
||
| class PolicyAttribute(Enum): | ||
| """ | ||
| Enumeration of Casbin policy attributes. | ||
|
|
||
| These attributes map to the columns of the CasbinRule table, but their meaning | ||
| depends on the policy type (ptype). Check the ``openedx_authz.engine.Filter`` class | ||
| for more details. | ||
| """ | ||
|
|
||
| PTYPE = "ptype" | ||
| """ptype (str): Type of policy""" | ||
|
|
||
| V0 = "v0" | ||
| """v0 (str): First policy value.""" | ||
|
|
||
| V1 = "v1" | ||
| """v1 (str): Second policy value.""" | ||
|
|
||
| V2 = "v2" | ||
| """v2 (str): Third policy value.""" | ||
|
|
||
| V3 = "v3" | ||
| """v3 (str): Fourth policy value.""" | ||
|
|
||
| V4 = "v4" | ||
| """v4 (str): Fifth policy value.""" | ||
|
|
||
| V5 = "v5" | ||
| """v5 (str): Sixth policy value.""" | ||
|
|
||
|
|
||
| class ExtendedAdapter(Adapter, FilteredAdapter): | ||
| """ | ||
| Extended Casbin adapter with filtering capabilities. | ||
|
|
||
| This adapter extends the base Django ORM Casbin adapter to support filtered | ||
| policy loading, allowing for more efficient policy management by loading | ||
| only relevant policy rules based on specified filter criteria. | ||
|
|
||
| Inherits from: | ||
| Adapter: Base Django adapter for Casbin policy persistence. | ||
| FilteredAdapter: Interface for filtered policy loading. | ||
| """ | ||
|
|
||
| def is_filtered(self) -> bool: | ||
| """ | ||
| Check if the adapter supports filtering. | ||
|
|
||
| Returns: | ||
| bool: True if the adapter supports filtered policy loading, False otherwise. | ||
| """ | ||
| return True | ||
mariajgrimaldi marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| def load_filtered_policy(self, model: Model, filter: Filter) -> None: # pylint: disable=redefined-builtin | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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:
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
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 In any case, looking at the bigger picture, I think this approach might work:
Do you think this makes sense?
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 I’d like to expand on two points you brought up:
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
| """ | ||
| Load policy rules from storage with filtering applied. | ||
|
|
||
| This method loads policy rules from the database and applies the specified | ||
| filter to load only relevant rules. The filtered rules are then loaded | ||
| into the provided Casbin model. | ||
|
|
||
| IMPORTANT: This method is used internally by the ``enforcer.load_filtered_policy()`` | ||
| method. Do not call this method directly. If you need to load policy rules, use | ||
| the ``enforcer.load_filtered_policy()`` method. | ||
|
|
||
| Args: | ||
| model (Model): The Casbin model to load policy rules into. | ||
mariajgrimaldi marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| filter (Filter): Filter object containing criteria for policy selection. | ||
| Should have attributes like ptype, v0, v1, etc. with lists | ||
| of values to filter by. | ||
| """ | ||
| queryset = CasbinRule.objects.using(self.db_alias) | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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? |
||
| filtered_queryset = self.filter_query(queryset, filter) | ||
| for line in filtered_queryset: | ||
| persist.load_policy_line(str(line), model) | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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!
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 It’s done the same way in the SQLAlchemy adapter
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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.
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Just to clarify. The 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.
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. |
||
|
|
||
| def filter_query(self, queryset: QuerySet, filter: Filter) -> QuerySet: # pylint: disable=redefined-builtin | ||
| """ | ||
| Apply filter criteria to the policy queryset. | ||
|
|
||
| This method takes a Django queryset of CasbinRule objects and applies | ||
| filtering based on the provided filter object's attributes. It supports | ||
| filtering by policy type (ptype) and policy values (v0-v5). | ||
|
|
||
| Args: | ||
| queryset (QuerySet): Django queryset of CasbinRule objects to filter. | ||
| filter (Filter): Filter object with attributes (ptype, v0, v1, v2, v3, v4, v5) | ||
| containing lists of values to filter by. Empty lists are ignored. | ||
|
|
||
| Returns: | ||
| QuerySet: Filtered and ordered queryset of CasbinRule objects. | ||
| """ | ||
| for attr in PolicyAttribute: | ||
| filter_values = getattr(filter, attr.value) | ||
| if len(filter_values) > 0: | ||
| filter_kwargs = {f"{attr.value}__in": filter_values} | ||
| queryset = queryset.filter(**filter_kwargs) | ||
| return queryset.order_by("id") | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,38 @@ | ||
| """ | ||
| Core authorization enforcer for Open edX AuthZ system. | ||
|
|
||
| Provides a Casbin FastEnforcer instance with extended adapter for database policy | ||
| storage and Redis watcher for distributed policy synchronization. | ||
|
|
||
| Components: | ||
| - Enforcer: Main FastEnforcer instance for policy evaluation | ||
| - Adapter: ExtendedAdapter for filtered database policy loading | ||
| - Watcher: Redis-based watcher for real-time policy updates | ||
|
|
||
| Usage: | ||
| from openedx_authz.engine.enforcer import enforcer | ||
| allowed = enforcer.enforce(user, resource, action) | ||
|
|
||
| Requires `CASBIN_MODEL` setting and Redis configuration for watcher functionality. | ||
| """ | ||
|
|
||
| import logging | ||
|
|
||
| from casbin import FastEnforcer | ||
| from django.conf import settings | ||
|
|
||
| from openedx_authz.engine.adapter import ExtendedAdapter | ||
| from openedx_authz.engine.watcher import Watcher | ||
|
|
||
| logger = logging.getLogger(__name__) | ||
|
|
||
| adapter = ExtendedAdapter() | ||
| enforcer = FastEnforcer(settings.CASBIN_MODEL, adapter, enable_log=True) | ||
| enforcer.enable_auto_save(True) | ||
|
|
||
| if Watcher: | ||
| try: | ||
| enforcer.set_watcher(Watcher) | ||
| logger.info("Watcher successfully set on Casbin enforcer") | ||
| except Exception as e: # pylint: disable=broad-exception-caught | ||
| logger.error(f"Failed to set watcher on Casbin enforcer: {e}") |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,80 @@ | ||
| """ | ||
| Filter Implementation for Casbin Policy Selection. | ||
|
|
||
| This module provides a Filter class used to specify criteria for selective | ||
| loading of Casbin policy rules. The Filter class allows for efficient policy | ||
| management by enabling the loading of only relevant policy rules based on | ||
| policy type and attribute values. | ||
|
|
||
| The Filter class is designed to work with the ExtendedAdapter to provide | ||
| optimized policy loading in scenarios where only a subset of policies | ||
| is needed, such as loading policies for a specific user, course, or role. | ||
| """ | ||
|
|
||
| from typing import Optional | ||
|
|
||
| import attr | ||
|
|
||
|
|
||
| @attr.define | ||
| class Filter: | ||
mariajgrimaldi marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| """ | ||
| Filter class for selective Casbin policy loading. | ||
|
|
||
| This class defines filtering criteria used to load only specific policy rules | ||
| from the database instead of loading all policies. Each attribute corresponds | ||
| to a column in the Casbin policy storage schema and accepts a list of values | ||
| to filter by. | ||
|
|
||
| Note: | ||
| - Empty lists for any attribute means no filtering on that attribute | ||
| - Non-empty lists create an "IN" filter for that attribute | ||
| - All non-empty filters are combined with AND logic | ||
| """ | ||
|
|
||
| ptype: Optional[list[str]] = attr.field(factory=list) | ||
| """ptype (Optional[list[str]]): Policy type filter. | ||
|
|
||
| - ``p`` → Policy rule (permissions). | ||
| - ``g`` → Grouping rule (user ↔ role). | ||
| - ``g2`` → Action grouping (parent action ↔ child action). | ||
| """ | ||
|
|
||
| v0: Optional[list[str]] = attr.field(factory=list) | ||
| """v0 (Optional[list[str]]): First policy value filter. | ||
|
|
||
| - For ``p`` → Subject (e.g., ``role:org_admin``, ``user:alice``). | ||
| - For ``g`` → User (e.g., ``user:alice``). | ||
| - For ``g2`` → Parent action (e.g., ``act:manage``). | ||
| """ | ||
|
|
||
| v1: Optional[list[str]] = attr.field(factory=list) | ||
| """v1 (Optional[list[str]]): Second policy value filter. | ||
|
|
||
| - For ``p`` → Action (e.g., ``act:manage``, ``act:edit``). | ||
| - For ``g`` → Role (e.g., ``role:org_admin``). | ||
| - For ``g2`` → Child action (e.g., ``act:edit``). | ||
| """ | ||
|
|
||
| v2: Optional[list[str]] = attr.field(factory=list) | ||
| """v2 (Optional[list[str]]): Third policy value filter. | ||
|
|
||
| - For ``p`` → Object or resource (e.g., ``lib:*``, ``org:MIT``). | ||
| - For ``g`` → Scope or resource (e.g., ``org:MIT``). | ||
| - For ``g2`` → Not used. | ||
| """ | ||
|
|
||
| v3: Optional[list[str]] = attr.field(factory=list) | ||
| """v3 (Optional[list[str]]): Fourth policy value filter. | ||
|
|
||
| - For ``p`` → Effect (allow or deny). | ||
| - Otherwise unused. | ||
| """ | ||
|
|
||
| v4: Optional[list[str]] = attr.field(factory=list) | ||
| """v4 (Optional[list[str]]): Fifth policy value filter (optional additional context). | ||
| """ | ||
|
|
||
| v5: Optional[list[str]] = attr.field(factory=list) | ||
| """v5 (Optional[list[str]]): Sixth policy value filter (optional additional context). | ||
| """ | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,59 @@ | ||
| """ | ||
| Redis-based policy change watcher for the authorization enforcer. | ||
|
|
||
| This module provides functionality to monitor policy changes in real-time using Redis | ||
| as a message broker. It enables automatic policy reloading across multiple instances | ||
| of the authorization system to maintain consistency and synchronization. | ||
|
|
||
| The watcher connects to Redis on the configured host and port, listens for policy | ||
| change events, and automatically triggers policy reloads when changes are detected. | ||
| This ensures that all running instances of the authorization system stay synchronized | ||
| with the latest policy configurations. | ||
| """ | ||
|
|
||
| import logging | ||
|
|
||
| from django.conf import settings | ||
| from redis_watcher import WatcherOptions, new_watcher | ||
|
|
||
| logger = logging.getLogger(__name__) | ||
|
|
||
|
|
||
| def callback_function(event) -> None: | ||
| """ | ||
| Enhanced callback function for the enforcer that reloads policies on changes. | ||
|
|
||
| This function is called whenever a policy change event is received through Redis. | ||
| It reloads the policies in the enforcer to ensure all instances stay synchronized. | ||
|
|
||
| Args: | ||
| event: The policy change event from Redis | ||
| """ | ||
| logger.info(f"Policy change event received: {event}") | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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? |
||
|
|
||
|
|
||
| def create_watcher(): | ||
| """ | ||
| Create and configure the Redis watcher for policy changes. | ||
|
|
||
| Returns: | ||
| The configured watcher instance | ||
| """ | ||
| watcher_options = WatcherOptions() | ||
| watcher_options.host = settings.REDIS_HOST | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
| watcher_options.port = settings.REDIS_PORT | ||
| watcher_options.optional_update_callback = callback_function | ||
|
|
||
| try: | ||
| watcher = new_watcher(watcher_options) | ||
| logger.info("Redis watcher created successfully") | ||
| return watcher | ||
| except Exception as e: | ||
| logger.error(f"Failed to create Redis watcher: {e}") | ||
| raise | ||
|
|
||
|
|
||
| if settings.CASBIN_WATCHER_ENABLED: | ||
| Watcher = create_watcher() | ||
| else: | ||
| Watcher = None | ||
Uh oh!
There was an error while loading. Please reload this page.