Skip to content

Commit 43a7654

Browse files
mariajgrimaldiMaferMazu
authored andcommitted
feat: filter libraries based on user-role scopes (openedx#37564)
(cherry picked from commit 6c6fc5d)
1 parent 45f94d4 commit 43a7654

File tree

3 files changed

+622
-4
lines changed

3 files changed

+622
-4
lines changed

openedx/core/djangoapps/content_libraries/api/libraries.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -267,7 +267,11 @@ def get_libraries_for_user(user, org=None, text_search=None, order=None) -> Quer
267267
Q(learning_package__description__icontains=text_search)
268268
)
269269

270-
filtered = permissions.perms[permissions.CAN_VIEW_THIS_CONTENT_LIBRARY].filter(user, qs)
270+
# Using distinct() temporarily to avoid duplicate results caused by overlapping permission checks
271+
# between Bridgekeeper and the new authorization framework. This ensures correct results for now,
272+
# but it should be removed once Bridgekeeper support is fully dropped and all permission logic
273+
# is handled through openedx-authz.
274+
filtered = permissions.perms[permissions.CAN_VIEW_THIS_CONTENT_LIBRARY].filter(user, qs).distinct()
271275

272276
if order:
273277
order_query = 'learning_package__'

openedx/core/djangoapps/content_libraries/permissions.py

Lines changed: 156 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,12 @@
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 import api as authz_api
10+
from openedx_authz.constants.permissions import VIEW_LIBRARY
711

812
from openedx.core.djangoapps.content_libraries.models import ContentLibraryPermission
913

@@ -54,6 +58,154 @@ def is_course_creator(user):
5458

5559
return get_course_creator_status(user) == 'granted'
5660

61+
62+
class HasPermissionInContentLibraryScope(Rule):
63+
"""Bridgekeeper rule that checks content library permissions via the openedx-authz system.
64+
65+
This rule integrates the openedx-authz authorization system (backed by Casbin) with
66+
Bridgekeeper's declarative permission system. It checks if a user has been granted a
67+
specific permission (action) through their role assignments in the authorization system.
68+
69+
The rule works by:
70+
1. Querying the authorization system to find library scopes where the user has this permission
71+
2. Parsing the library keys (org/slug) from the scopes
72+
3. Building database filters to match ContentLibrary models with those org/slug combinations
73+
74+
Attributes:
75+
permission (PermissionData): The permission object representing the action to check
76+
(e.g., 'view', 'edit'). This is used to look up scopes in the authorization system.
77+
78+
filter_keys (list[str]): The Django model fields to use when building QuerySet filters.
79+
Defaults to ['org', 'slug'] for ContentLibrary models.
80+
81+
These fields are used to construct the Q object filters that match libraries
82+
based on the parsed components from library keys in authorization scopes.
83+
84+
For ContentLibrary, library keys have the format 'lib:ORG:SLUG', which maps to:
85+
- 'org' -> filters on org__short_name (related Organization model)
86+
- 'slug' -> filters on slug field
87+
88+
If filtering by different fields is needed, pass a custom list. For example:
89+
- ['org', 'slug'] - default for ContentLibrary (filters by org and slug)
90+
- ['id'] - filter by primary key (for other models)
91+
92+
Examples:
93+
Basic usage with default filter_keys:
94+
>>> from bridgekeeper import perms
95+
>>> from openedx.core.djangoapps.content_libraries.permissions import HasPermissionInContentLibraryScope
96+
>>>
97+
>>> # Uses default filter_keys=['org', 'slug'] for ContentLibrary
98+
>>> can_view = HasPermissionInContentLibraryScope('view_library')
99+
>>> perms['libraries.view_library'] = can_view
100+
101+
Compound permissions with boolean operators:
102+
>>> from bridgekeeper.rules import Attribute
103+
>>>
104+
>>> is_active = Attribute('is_active', True)
105+
>>> is_staff = Attribute('is_staff', True)
106+
>>> can_view = HasPermissionInContentLibraryScope('view_library')
107+
>>>
108+
>>> # User must be active AND (staff OR have explicit permission)
109+
>>> perms['libraries.view_library'] = is_active & (is_staff | can_view)
110+
111+
QuerySet filtering (efficient, database-level):
112+
>>> from openedx.core.djangoapps.content_libraries.models import ContentLibrary
113+
>>>
114+
>>> # Gets all libraries user can view in a single SQL query
115+
>>> visible_libraries = perms['libraries.view_library'].filter(
116+
... request.user,
117+
... ContentLibrary.objects.all()
118+
... )
119+
120+
Individual object checks:
121+
>>> library = ContentLibrary.objects.get(org__short_name='DemoX', slug='CSPROB')
122+
>>> if perms['libraries.view_library'].check(request.user, library):
123+
... # User can view this specific library
124+
125+
Note:
126+
The library keys in authorization scopes must have the format 'lib:ORG:SLUG'
127+
to match the ContentLibrary model's org.short_name and slug fields.
128+
For example, scope 'lib:DemoX:CSPROB' matches a library with
129+
org.short_name='DemoX' and slug='CSPROB'.
130+
"""
131+
132+
def __init__(self, permission: authz_api.PermissionData, filter_keys: list[str] | None = None):
133+
"""Initialize the rule with the action and filter keys to filter on.
134+
135+
Args:
136+
permission (PermissionData): The permission to check (e.g., 'view', 'edit').
137+
filter_keys (list[str]): The model fields to filter on when building QuerySet filters.
138+
Defaults to ['org', 'slug'] for ContentLibrary.
139+
"""
140+
self.permission = permission
141+
self.filter_keys = filter_keys if filter_keys is not None else ["org", "slug"]
142+
143+
def query(self, user):
144+
"""Convert this rule to a Django Q object for QuerySet filtering.
145+
146+
Args:
147+
user: The Django user object (must have a 'username' attribute).
148+
149+
Returns:
150+
Q: A Django Q object that can be used to filter a QuerySet.
151+
The Q object combines multiple conditions using OR (|) operators,
152+
where each condition matches a library's org and slug fields:
153+
Q(org__short_name='OrgA' & slug='lib-a') | Q(org__short_name='OrgB' & slug='lib-b')
154+
155+
Example:
156+
>>> # User has 'view' permission in scopes: ['lib:OrgA:lib-a', 'lib:OrgB:lib-b']
157+
>>> rule = HasPermissionInContentLibraryScope('view', filter_keys=['org', 'slug'])
158+
>>> q = rule.query(user)
159+
>>> # Results in: Q(org__short_name='OrgA', slug='lib-a') | Q(org__short_name='OrgB', slug='lib-b')
160+
>>>
161+
>>> # Apply to queryset
162+
>>> libraries = ContentLibrary.objects.filter(q)
163+
>>> # SQL: SELECT * FROM content_library
164+
>>> # WHERE (org.short_name='OrgA' AND slug='lib-a')
165+
>>> # OR (org.short_name='OrgB' AND slug='lib-b')
166+
"""
167+
scopes = authz_api.get_scopes_for_user_and_permission(
168+
user.username,
169+
self.permission.identifier
170+
)
171+
172+
library_keys = [scope.library_key for scope in scopes]
173+
174+
if not library_keys:
175+
return Q(pk__in=[]) # No access, return Q that matches nothing
176+
177+
# Build Q object: OR together (org AND slug) conditions for each library
178+
query = Q()
179+
for library_key in library_keys:
180+
query |= Q(org__short_name=library_key.org, slug=library_key.slug)
181+
182+
return query
183+
184+
def check(self, user, instance, *args, **kwargs): # pylint: disable=arguments-differ
185+
"""Check if user has permission for a specific object instance.
186+
187+
This method is used for checking permission on individual objects rather
188+
than filtering a QuerySet. It extracts the scope from the object and
189+
checks if the user has the required permission in that scope via Casbin.
190+
191+
Args:
192+
user: The Django user object (must have a 'username' attribute).
193+
instance: The Django model instance to check permission for.
194+
*args: Additional positional arguments (for compatibility with parent signature).
195+
**kwargs: Additional keyword arguments (for compatibility with parent signature).
196+
197+
Returns:
198+
bool: True if the user has the permission in the object's scope,
199+
False otherwise.
200+
201+
Example:
202+
>>> rule = HasPermissionInContentLibraryScope('view')
203+
>>> can_view = rule.check(user, library)
204+
>>> # Checks if user has 'view' permission in scope 'lib:DemoX:CSPROB'
205+
"""
206+
return authz_api.is_user_allowed(user.username, self.permission.identifier, str(instance.library_key))
207+
208+
57209
########################### Permissions ###########################
58210

59211
# Is the user allowed to view XBlocks from the specified content library
@@ -87,7 +239,9 @@ def is_course_creator(user):
87239
is_global_staff |
88240
# Libraries with "public read" permissions can be accessed only by course creators
89241
(Attribute('allow_public_read', True) & is_course_creator) |
90-
# Otherwise the user must be part of the library's team
242+
# Users can access libraries within their authorized scope (via Casbin/role-based permissions)
243+
HasPermissionInContentLibraryScope(VIEW_LIBRARY) |
244+
# Fallback to: the user must be part of the library's team (legacy permission system)
91245
has_explicit_read_permission_for_library
92246
)
93247

0 commit comments

Comments
 (0)