Skip to content

Commit 0d1fa11

Browse files
feat: use filter based on scopes alongside permissions dict (bridgekeeper)
1 parent 81fc0bd commit 0d1fa11

File tree

2 files changed

+438
-2
lines changed

2 files changed

+438
-2
lines changed

openedx/core/djangoapps/content_libraries/permissions.py

Lines changed: 178 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,11 @@
22
Permissions for Content Libraries (v2, Learning-Core-based)
33
"""
44
from bridgekeeper import perms, rules
5-
from bridgekeeper.rules import Attribute, ManyRelation, Relation, blanket_rule, in_current_groups
5+
from bridgekeeper.rules import Attribute, ManyRelation, Relation, blanket_rule, in_current_groups, Rule
66
from django.conf import settings
7+
from django.db.models import Q
8+
9+
from openedx_authz.api.users import is_user_allowed, get_scopes_for_user_and_permission
710

811
from openedx.core.djangoapps.content_libraries.models import ContentLibraryPermission
912

@@ -54,6 +57,177 @@ def is_course_creator(user):
5457

5558
return get_course_creator_status(user) == 'granted'
5659

60+
61+
class HasPermissionInContentLibraryScope(Rule):
62+
"""Bridgekeeper rule that checks permissions via Casbin's policy engine.
63+
64+
This rule integrates Casbin's role-based authorization with Bridgekeeper's
65+
declarative permission system. It checks if a user has been granted a specific
66+
permission (action) through their role assignments in Casbin.
67+
68+
The rule works by:
69+
1. Querying Casbin grouping policies to find the user's role assignments
70+
2. Querying Casbin permission policies to find which roles grant the action
71+
3. Matching role assignments with scopes to determine where the user has permission
72+
73+
This enables both individual object permission checks and efficient QuerySet
74+
filtering - a key feature that allows database-level filtering instead of
75+
checking each object individually.
76+
77+
Attributes:
78+
action_external_key (str): The action/permission to check (e.g., 'view', 'edit').
79+
This should be the external key WITHOUT the namespace prefix.
80+
For example, use 'view' not 'act^view'.
81+
82+
scope_field (str): The Django model field/property that contains the scope identifier.
83+
This tells the rule WHERE to find the scope value in your model.
84+
Defaults to 'id'.
85+
86+
**IMPORTANT**: This can be a model property (like `library_key`) or a field.
87+
For ContentLibrary, use 'library_key' which is a @property that returns
88+
the LibraryLocatorV2 string representation.
89+
90+
The scope_field serves two purposes:
91+
- **For QuerySet filtering**: Builds SQL like `WHERE scope_field IN (...)`
92+
- **For object checks**: Extracts the scope from `instance.scope_field`
93+
94+
Supports Django ORM field lookups for nested fields:
95+
- 'library_key' - a @property on the model (ContentLibrary case)
96+
- 'id' - direct field on the model
97+
- 'library__id' - field on a related model
98+
- 'course__org__key' - multi-level relationship
99+
100+
Examples:
101+
Basic usage with default scope_field:
102+
>>> from bridgekeeper import perms
103+
>>> from openedx_authz.permissions import HasPermissionInScope
104+
>>>
105+
>>> # Assumes the model's 'id' field contains the scope
106+
>>> can_view = HasPermissionInScope('view')
107+
>>> perms['libraries.view'] = can_view
108+
109+
Specifying a custom scope_field:
110+
>>> # When scope is in a field named 'library_id'
111+
>>> can_view = HasPermissionInScope('view', scope_field='library_id')
112+
>>>
113+
>>> # When scope is in a related model
114+
>>> can_manage = HasPermissionInScope('manage', scope_field='library__key')
115+
116+
Compound permissions with boolean operators:
117+
>>> from bridgekeeper.rules import Attribute
118+
>>>
119+
>>> is_active = Attribute('is_active', True)
120+
>>> is_staff = Attribute('is_staff', True)
121+
>>> can_view = HasPermissionInScope('view', scope_field='library_id')
122+
>>>
123+
>>> # User must be active AND (staff OR have explicit permission)
124+
>>> perms['libraries.view'] = is_active & (is_staff | can_view)
125+
126+
QuerySet filtering (efficient, database-level):
127+
>>> from openedx.core.djangoapps.content_libraries.models import ContentLibrary
128+
>>>
129+
>>> # Gets all libraries user can view in a single SQL query
130+
>>> visible_libraries = perms['libraries.view'].filter(
131+
... request.user,
132+
... ContentLibrary.objects.all()
133+
... )
134+
135+
Individual object checks:
136+
>>> library = ContentLibrary.objects.get(library_id='lib:DemoX:CSPROB')
137+
>>> if perms['libraries.view'].check(request.user, library):
138+
... # User can view this specific library
139+
... return render_library(library)
140+
141+
Note:
142+
The scope identifiers in Casbin policies must match the values in your
143+
Django model's scope_field. For example, if Casbin stores
144+
'lib:DemoX:CSPROB' and your model has library_id='lib:DemoX:CSPROB',
145+
they must match exactly (including format and casing).
146+
"""
147+
148+
def __init__(self, action_external_key: str, filter_keys: list[str] = ["org", "slug"]):
149+
"""Initialize the rule with the action and filter keys to filter on.
150+
151+
Args:
152+
action_external_key (str): The action/permission to check (e.g., 'view', 'edit').
153+
filter_keys (list[str]): The model fields to filter on when building QuerySet filters.
154+
Defaults to ['org', 'slug'] for ContentLibrary.
155+
"""
156+
self.action_external_key = action_external_key
157+
self.filter_keys = filter_keys
158+
159+
def query(self, user):
160+
"""Convert this rule to a Django Q object for QuerySet filtering.
161+
162+
This method enables efficient database-level filtering by:
163+
1. Querying the authorization system to get ALL library scopes where the user has this permission
164+
2. Parsing the library keys (org/slug pairs) from the scopes
165+
3. Building a Django Q object that filters for libraries matching those org/slug combinations
166+
167+
This avoids N+1 query problems by filtering at the database level rather
168+
than checking permission for each object individually.
169+
170+
Args:
171+
user: The Django user object (must have a 'username' attribute).
172+
173+
Returns:
174+
Q: A Django Q object that can be used to filter a QuerySet.
175+
The Q object combines multiple conditions using OR (|) operators,
176+
where each condition matches a library's org and slug fields:
177+
Q(org__short_name='OrgA' & slug='lib-a') | Q(org__short_name='OrgB' & slug='lib-b')
178+
179+
Example:
180+
>>> # User has 'view' permission in scopes: ['lib:OrgA:lib-a', 'lib:OrgB:lib-b']
181+
>>> rule = HasPermissionInContentLibraryScope('view', filter_keys=['org', 'slug'])
182+
>>> q = rule.query(user)
183+
>>> # Results in: Q(org__short_name='OrgA', slug='lib-a') | Q(org__short_name='OrgB', slug='lib-b')
184+
>>>
185+
>>> # Apply to queryset
186+
>>> libraries = ContentLibrary.objects.filter(q)
187+
>>> # SQL: SELECT * FROM content_library
188+
>>> # WHERE (org.short_name='OrgA' AND slug='lib-a')
189+
>>> # OR (org.short_name='OrgB' AND slug='lib-b')
190+
"""
191+
scopes = get_scopes_for_user_and_permission(
192+
user.username,
193+
self.action_external_key
194+
)
195+
196+
library_keys = [scope.library_key for scope in scopes]
197+
198+
if not library_keys:
199+
return Q(pk__in=[]) # No access, return Q that matches nothing
200+
201+
# Build Q object: OR together (org AND slug) conditions for each library
202+
query = Q()
203+
for library_key in library_keys:
204+
query |= Q(org__short_name=library_key.org, slug=library_key.slug)
205+
206+
return query
207+
208+
def check(self, user, instance):
209+
"""Check if user has permission for a specific object instance.
210+
211+
This method is used for checking permission on individual objects rather
212+
than filtering a QuerySet. It extracts the scope from the object and
213+
checks if the user has the required permission in that scope via Casbin.
214+
215+
Args:
216+
user: The Django user object (must have a 'username' attribute).
217+
instance: The Django model instance to check permission for.
218+
219+
Returns:
220+
bool: True if the user has the permission in the object's scope,
221+
False otherwise.
222+
223+
Example:
224+
>>> rule = HasPermissionInScope('view')
225+
>>> can_view = rule.check(user, library)
226+
>>> # Checks if user has 'view' permission in scope 'lib:DemoX:CSPROB'
227+
"""
228+
return is_user_allowed(user.username, self.action_external_key, str(instance.library_key))
229+
230+
57231
########################### Permissions ###########################
58232

59233
# Is the user allowed to view XBlocks from the specified content library
@@ -87,7 +261,9 @@ def is_course_creator(user):
87261
is_global_staff |
88262
# Libraries with "public read" permissions can be accessed only by course creators
89263
(Attribute('allow_public_read', True) & is_course_creator) |
90-
# Otherwise the user must be part of the library's team
264+
# Users can access libraries within their authorized scope (via Casbin/role-based permissions)
265+
HasPermissionInContentLibraryScope("view_library") |
266+
# Fallback to: the user must be part of the library's team (legacy permission system)
91267
has_explicit_read_permission_for_library
92268
)
93269

0 commit comments

Comments
 (0)