Skip to content

Commit c28c61e

Browse files
authored
feat: add api function for get user role assignments filtered (#240)
1 parent 16a6bbb commit c28c61e

7 files changed

Lines changed: 433 additions & 14 deletions

File tree

CHANGELOG.rst

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,15 @@ Change Log
1414
Unreleased
1515
**********
1616

17+
1.2.0 - 2026-03-30
18+
******************
19+
20+
Added
21+
=====
22+
23+
* Add ``get_user_role_assignments_filtered`` api function to fetch user role assignments filtered by user, role, and/or scope.
24+
* Add ``org`` property to ``ContentLibraryData`` and ``CourseOverviewData``.
25+
1726
1.1.0 - 2026-03-17
1827
******************
1928

openedx_authz/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,6 @@
44

55
import os
66

7-
__version__ = "1.1.0"
7+
__version__ = "1.2.0"
88

99
ROOT_DIRECTORY = os.path.dirname(os.path.abspath(__file__))

openedx_authz/api/data.py

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import re
66
from abc import abstractmethod
77
from enum import Enum
8+
from functools import cached_property
89
from typing import Any, ClassVar, Literal, Type
910

1011
from attrs import define
@@ -448,6 +449,15 @@ class ContentLibraryData(ScopeData):
448449
NAMESPACE: ClassVar[str] = "lib"
449450
ID_SEPARATOR: ClassVar[str] = ":"
450451

452+
@property
453+
def org(self) -> str:
454+
"""Get the organization name from the library key.
455+
456+
Returns:
457+
str: The organization name (e.g., ``DemoX`` from ``lib:DemoX:CSPROB``).
458+
"""
459+
return self.library_key.org
460+
451461
@property
452462
def library_id(self) -> str:
453463
"""The library identifier as used in Open edX (e.g., 'lib:DemoX:CSPROB').
@@ -459,7 +469,7 @@ def library_id(self) -> str:
459469
"""
460470
return self.external_key
461471

462-
@property
472+
@cached_property
463473
def library_key(self) -> LibraryLocatorV2:
464474
"""The LibraryLocatorV2 object for the content library.
465475
@@ -552,6 +562,15 @@ class CourseOverviewData(ScopeData):
552562
NAMESPACE: ClassVar[str] = "course-v1"
553563
ID_SEPARATOR: ClassVar[str] = "+"
554564

565+
@property
566+
def org(self) -> str:
567+
"""Get the organization name from the course key.
568+
569+
Returns:
570+
str: The organization name (e.g., ``DemoX`` from ``course-v1:DemoX+TestCourse+2024_T1``).
571+
"""
572+
return self.course_key.org
573+
555574
@property
556575
def course_id(self) -> str:
557576
"""The course identifier as used in Open edX (e.g., 'course-v1:TestOrg+TestCourse+2024_T1').
@@ -563,7 +582,7 @@ def course_id(self) -> str:
563582
"""
564583
return self.external_key
565584

566-
@property
585+
@cached_property
567586
def course_key(self) -> CourseKey:
568587
"""The CourseKey object for the course.
569588

openedx_authz/api/roles.py

Lines changed: 97 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -26,21 +26,22 @@
2626
from openedx_authz.models import ExtendedCasbinRule
2727

2828
__all__ = [
29-
"get_permissions_for_single_role",
30-
"get_permissions_for_roles",
31-
"get_all_roles_names",
32-
"get_all_roles_in_scope",
33-
"get_permissions_for_active_roles_in_scope",
34-
"get_role_definitions_in_scope",
3529
"assign_role_to_subject_in_scope",
3630
"batch_assign_role_to_subjects_in_scope",
37-
"unassign_role_from_subject_in_scope",
3831
"batch_unassign_role_from_subjects_in_scope",
39-
"get_subject_role_assignments_in_scope",
40-
"get_subject_role_assignments_for_role_in_scope",
32+
"get_all_roles_in_scope",
33+
"get_all_roles_names",
4134
"get_all_subject_role_assignments_in_scope",
42-
"get_subject_role_assignments",
35+
"get_permissions_for_active_roles_in_scope",
36+
"get_permissions_for_roles",
37+
"get_permissions_for_single_role",
38+
"get_role_assignments",
39+
"get_role_definitions_in_scope",
4340
"get_scopes_for_subject_and_permission",
41+
"get_subject_role_assignments",
42+
"get_subject_role_assignments_for_role_in_scope",
43+
"get_subject_role_assignments_in_scope",
44+
"unassign_role_from_subject_in_scope",
4445
"unassign_subject_from_all_roles",
4546
]
4647

@@ -293,6 +294,92 @@ def get_subject_role_assignments(subject: SubjectData) -> list[RoleAssignmentDat
293294
return role_assignments
294295

295296

297+
def _get_field_index_and_values(
298+
subject: SubjectData | None,
299+
role: RoleData | None,
300+
scope: ScopeData | None,
301+
) -> tuple[int, list[str]]:
302+
"""Build field index and values for Casbin's get_filtered_grouping_policy.
303+
304+
Returns the leftmost non-None field as field_index and a list of consecutive
305+
values starting from that index. Empty strings serve as wildcards for positions
306+
between specified values.
307+
308+
Args:
309+
subject: Optional subject to filter by.
310+
role: Optional role to filter by.
311+
scope: Optional scope to filter by.
312+
313+
Returns:
314+
tuple: (field_index, field_values) where field_index is the starting position
315+
and field_values are the consecutive filter values from that position.
316+
317+
Examples:
318+
>>> _get_field_index_and_values(user, None, None)
319+
(0, ['user^steve'])
320+
>>> _get_field_index_and_values(user, role, None)
321+
(0, ['user^steve', 'role^course_admin'])
322+
>>> _get_field_index_and_values(None, role, scope)
323+
(1, ['role^course_admin', 'course-v1^course-v1:OpenedX+Demo+Course'])
324+
>>> _get_field_index_and_values(user, None, scope)
325+
(0, ['user^steve', '', 'course-v1^course-v1:OpenedX+Demo+Course'])
326+
>>> _get_field_index_and_values(None, None, scope)
327+
(2, ['course-v1^course-v1:OpenedX+Demo+Course'])
328+
"""
329+
fields = [subject, role, scope]
330+
field_index = 0
331+
332+
for index, field in enumerate(fields):
333+
if field is not None:
334+
field_index = index
335+
break
336+
337+
values = [field.namespaced_key if field else "" for field in fields]
338+
339+
# Take slice from first defined field
340+
field_values = values[field_index:]
341+
342+
# Remove trailing wildcards
343+
while field_values and field_values[-1] == "":
344+
field_values.pop()
345+
346+
return field_index, field_values
347+
348+
349+
def get_role_assignments(
350+
*,
351+
subject: SubjectData | None = None,
352+
role: RoleData | None = None,
353+
scope: ScopeData | None = None,
354+
) -> list[RoleAssignmentData]:
355+
"""Get all the roles for a subject across all scopes filtered by the given filters.
356+
357+
Args:
358+
subject: Optional SubjectData object to filter by.
359+
role: Optional RoleData object to filter by.
360+
scope: Optional ScopeData object to filter by.
361+
362+
Returns:
363+
list[RoleAssignmentData]: A list of RoleAssignmentData objects filtered by the given filters.
364+
"""
365+
enforcer = AuthzEnforcer.get_enforcer()
366+
role_assignments = []
367+
field_index, field_values = _get_field_index_and_values(subject, role, scope)
368+
policies = enforcer.get_filtered_grouping_policy(field_index, *field_values)
369+
370+
for policy in policies:
371+
role = RoleData(namespaced_key=policy[GroupingPolicyIndex.ROLE.value])
372+
role.permissions = get_permissions_for_single_role(role)
373+
role_assignments.append(
374+
RoleAssignmentData(
375+
subject=SubjectData(namespaced_key=policy[GroupingPolicyIndex.SUBJECT.value]),
376+
roles=[role],
377+
scope=ScopeData(namespaced_key=policy[GroupingPolicyIndex.SCOPE.value]),
378+
)
379+
)
380+
return role_assignments
381+
382+
296383
def get_subject_role_assignments_in_scope(subject: SubjectData, scope: ScopeData) -> list[RoleAssignmentData]:
297384
"""Get the roles for a subject in a specific scope.
298385

openedx_authz/api/users.py

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,21 @@
99
(e.g., 'user^john_doe').
1010
"""
1111

12-
from openedx_authz.api.data import ActionData, PermissionData, RoleAssignmentData, RoleData, ScopeData, UserData
12+
from openedx_authz.api.data import (
13+
ActionData,
14+
PermissionData,
15+
RoleAssignmentData,
16+
RoleData,
17+
ScopeData,
18+
UserData,
19+
)
1320
from openedx_authz.api.permissions import is_subject_allowed
1421
from openedx_authz.api.roles import (
1522
assign_role_to_subject_in_scope,
1623
batch_assign_role_to_subjects_in_scope,
1724
batch_unassign_role_from_subjects_in_scope,
1825
get_all_subject_role_assignments_in_scope,
26+
get_role_assignments,
1927
get_scopes_for_subject_and_permission,
2028
get_subject_role_assignments,
2129
get_subject_role_assignments_for_role_in_scope,
@@ -33,6 +41,7 @@
3341
"get_user_role_assignments",
3442
"get_user_role_assignments_in_scope",
3543
"get_user_role_assignments_for_role_in_scope",
44+
"get_user_role_assignments_filtered",
3645
"get_all_user_role_assignments_in_scope",
3746
"is_user_allowed",
3847
"get_scopes_for_user_and_permission",
@@ -155,6 +164,33 @@ def get_user_role_assignments_for_role_in_scope(
155164
)
156165

157166

167+
def get_user_role_assignments_filtered(
168+
*,
169+
user_external_key: str | None = None,
170+
role_external_key: str | None = None,
171+
scope_external_key: str | None = None,
172+
) -> list[RoleAssignmentData]:
173+
"""Get role assignments filtered by user, role, and/or scope.
174+
175+
This function provides flexible filtering of role assignments by any combination
176+
of user, role, and scope. At least one filter parameter should be provided for
177+
meaningful results.
178+
179+
Args:
180+
user_external_key: Optional user ID to filter by (e.g., 'john_doe').
181+
role_external_key: Optional role name to filter by (e.g., 'library_admin').
182+
scope_external_key: Optional scope to filter by (e.g., 'lib:DemoX:CSPROB').
183+
184+
Returns:
185+
list[RoleAssignmentData]: Filtered role assignments.
186+
"""
187+
return get_role_assignments(
188+
subject=UserData(external_key=user_external_key) if user_external_key else None,
189+
role=RoleData(external_key=role_external_key) if role_external_key else None,
190+
scope=ScopeData(external_key=scope_external_key) if scope_external_key else None,
191+
)
192+
193+
158194
def get_all_user_role_assignments_in_scope(
159195
scope_external_key: str,
160196
) -> list[RoleAssignmentData]:

openedx_authz/tests/api/test_data.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,28 @@ def test_scope_content_lib_data_namespace(self, external_key):
9898

9999
self.assertEqual(scope.namespaced_key, expected)
100100

101+
@data(
102+
("lib:DemoX:CSPROB", "DemoX"),
103+
("lib:Org1:math_101", "Org1"),
104+
)
105+
@unpack
106+
def test_content_library_data_org_property(self, external_key, expected_org):
107+
"""Test that ContentLibraryData returns the correct organization name."""
108+
scope = ContentLibraryData(external_key=external_key)
109+
110+
self.assertEqual(scope.org, expected_org)
111+
112+
@data(
113+
("course-v1:DemoX+TestCourse+2024_T1", "DemoX"),
114+
("course-v1:WGU+CS002+2025_T1", "WGU"),
115+
)
116+
@unpack
117+
def test_course_overview_data_org_property(self, external_key, expected_org):
118+
"""Test that CourseOverviewData returns the correct organization name."""
119+
scope = CourseOverviewData(external_key=external_key)
120+
121+
self.assertEqual(scope.org, expected_org)
122+
101123

102124
@ddt
103125
class TestPolymorphicData(TestCase):

0 commit comments

Comments
 (0)