Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions openedx_authz/api/data.py
Original file line number Diff line number Diff line change
Expand Up @@ -1545,6 +1545,22 @@ def __repr__(self):
role_keys = ", ".join(role.namespaced_key for role in self.roles)
return f"{self.subject.namespaced_key} => [{role_keys}] @ {self.scope.namespaced_key}"

@staticmethod
def filter_platform_glob_assignments(

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe we can start encapsulating these methods here so we can reuse them later and we don't introduce so much of the derivation logic into the views.

assignments: list["RoleAssignmentData"],
) -> list["RoleAssignmentData"]:
"""Filter a list of role assignments down to those in a platform-level glob scope.

Args:
assignments: Role assignments to filter.

Returns:
list[RoleAssignmentData]: The assignments whose scope is a platform-level glob
(e.g., PlatformCourseOverviewGlobData, PlatformContentLibraryGlobData).
"""
platform_glob_scopes = tuple(ScopeData.get_all_platform_glob_namespaces().values())
return [assignment for assignment in assignments if isinstance(assignment.scope, platform_glob_scopes)]


@define
class SuperAdminAssignmentData:
Expand Down
32 changes: 29 additions & 3 deletions openedx_authz/rest_api/v1/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,12 +30,14 @@
OrgCourseOverviewGlobData,
PlatformGlobData,
RoleAssignmentData,
ScopeData,
SuperAdminAssignmentData,
UserAssignmentData,
)
from openedx_authz.api.users import (
get_scopes_for_user_and_permission,
get_superadmin_assignments,
get_user_role_assignments_per_scope_type,
get_visible_user_role_assignments_filtered_by_current_user,
)
from openedx_authz.api.utils import get_user_map
Expand Down Expand Up @@ -565,9 +567,12 @@ class AdminConsoleOrgsAPIView(generics.ListAPIView):
- name: The organization's name
- short_name: The organization's short name

Results are limited to the orgs of the courses and libraries the requesting user has a
role assignment in. Staff and superusers see every active org.

**Authentication and Permissions**

- Requires authenticated user.
- Requires authenticated user with either a content library or course view team permission.

**Example Request**

Expand Down Expand Up @@ -601,8 +606,29 @@ class AdminConsoleOrgsAPIView(generics.ListAPIView):
permission_classes = [AnyScopePermission]

def get_queryset(self) -> QuerySet:
"""Return active organizations ordered by name."""
return Organization.objects.filter(active=True).order_by("name")
"""Return the active organizations visible to the requesting user, ordered by name."""
user = self.request.user

orgs = Organization.objects.filter(active=True).order_by("name")

# Staff/superusers bypass assignment-based filtering, same as elsewhere in the codebase
# (see _filter_allowed_assignments and the Casbin matcher). TODO: this check is duplicated
# at each call site that needs it; it should live once in the api layer instead of being
# re-implemented in the REST API layer.
# See https://github.com/openedx/openedx-authz/issues/347 to standardize this further.
if user.is_staff or user.is_superuser:
return orgs

assignments = get_user_role_assignments_per_scope_type(
user.username,
tuple(ScopeData.get_all_registered_scopes()),
)

if RoleAssignmentData.filter_platform_glob_assignments(assignments):
return orgs

short_names = {org for assignment in assignments if (org := getattr(assignment.scope, "org", None))}
return orgs.filter(short_name__in=short_names)


@view_auth_classes()
Expand Down
67 changes: 67 additions & 0 deletions openedx_authz/tests/api/test_data.py
Original file line number Diff line number Diff line change
Expand Up @@ -855,6 +855,73 @@ def test_role_assignment_data_repr(self):
expected_repr = "user^john_doe => [role^instructor, role^library_admin] @ lib^lib:DemoX:CSPROB"
self.assertEqual(actual_repr, expected_repr)

def test_filter_platform_glob_assignments_keeps_only_platform_glob_scopes(self):
"""Test that filter_platform_glob_assignments keeps only platform-level glob assignments.

Expected Result:
- Assignments scoped to PlatformCourseOverviewGlobData and PlatformContentLibraryGlobData
are kept
- Assignments scoped to a specific library, a specific course, and an org-level glob
are dropped
"""
user = UserData(external_key="john_doe")
role = RoleData(external_key="instructor")
platform_course_assignment = RoleAssignmentData(
subject=user,
roles=[role],
scope=PlatformCourseOverviewGlobData(external_key=PlatformCourseOverviewGlobData.build_external_key()),
)
platform_library_assignment = RoleAssignmentData(
subject=user,
roles=[role],
scope=PlatformContentLibraryGlobData(external_key=PlatformContentLibraryGlobData.build_external_key()),
)
specific_library_assignment = RoleAssignmentData(
subject=user, roles=[role], scope=ContentLibraryData(external_key="lib:DemoX:CSPROB")
)
specific_course_assignment = RoleAssignmentData(
subject=user, roles=[role], scope=CourseOverviewData(external_key="course-v1:DemoX+CS101+2024")
)
org_glob_assignment = RoleAssignmentData(
subject=user, roles=[role], scope=OrgContentLibraryGlobData(external_key="lib:DemoX:*")
)

result = RoleAssignmentData.filter_platform_glob_assignments(
[
platform_course_assignment,
platform_library_assignment,
specific_library_assignment,
specific_course_assignment,
org_glob_assignment,
]
)

self.assertEqual(result, [platform_course_assignment, platform_library_assignment])

def test_filter_platform_glob_assignments_empty_list(self):
"""Test that filter_platform_glob_assignments returns an empty list when given no assignments.

Expected Result:
- Returns an empty list
"""
self.assertEqual(RoleAssignmentData.filter_platform_glob_assignments([]), [])

def test_filter_platform_glob_assignments_no_matches(self):
"""Test that filter_platform_glob_assignments returns an empty list when none match.

Expected Result:
- Returns an empty list when no assignment is scoped to a platform-level glob
"""
user = UserData(external_key="john_doe")
role = RoleData(external_key="instructor")
specific_library_assignment = RoleAssignmentData(
subject=user, roles=[role], scope=ContentLibraryData(external_key="lib:DemoX:CSPROB")
)

result = RoleAssignmentData.filter_platform_glob_assignments([specific_library_assignment])

self.assertEqual(result, [])


@ddt
class TestContentLibraryData(TestCase):
Expand Down
123 changes: 123 additions & 0 deletions openedx_authz/tests/rest_api/test_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -2171,6 +2171,129 @@ def test_get_orgs_unauthenticated(self):

self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)

def test_django_staff_sees_all_orgs_regardless_of_assignments(self):
"""Test that a Django staff/superuser sees every active org, not just the ones from their assignments.

admin_2's only role assignment is in scope "lib:Org2:LIB2" (org "Org2"), which doesn't match
any of the AlphaU/BetaI/GammaC fixtures, yet all 3 must still be returned.

Expected result:
- Returns 200 OK status
- Returns all 3 orgs
"""
user = User.objects.get(username="admin_2")
self.client.force_authenticate(user=user)

response = self.client.get(self.url)

self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.data["count"], 3)

@data(
# Specific (non-glob) scopes
([("lib:AlphaU:LIB_ADMIN_TEST", roles.LIBRARY_USER.external_key)], {"AlphaU"}),
([("course-v1:BetaI+COURSE1+2024", roles.COURSE_STAFF.external_key)], {"BetaI"}),
# Org-level glob scopes
([(OrgContentLibraryGlobData.build_external_key("AlphaU"), roles.LIBRARY_ADMIN.external_key)], {"AlphaU"}),
([(OrgCourseOverviewGlobData.build_external_key("BetaI"), roles.COURSE_STAFF.external_key)], {"BetaI"}),
# Multiple assignments across different orgs and scope types
(
[
("course-v1:AlphaU+COURSE1+2024", roles.COURSE_STAFF.external_key),
(OrgContentLibraryGlobData.build_external_key("BetaI"), roles.LIBRARY_ADMIN.external_key),
],
{"AlphaU", "BetaI"},
),
# Two specific course scopes, different orgs
(
[
("course-v1:AlphaU+COURSE1+2024", roles.COURSE_STAFF.external_key),
("course-v1:BetaI+COURSE2+2024", roles.COURSE_STAFF.external_key),
],
{"AlphaU", "BetaI"},
),
# Two org-level glob scopes, different orgs
(
[
(OrgContentLibraryGlobData.build_external_key("AlphaU"), roles.LIBRARY_ADMIN.external_key),
(OrgCourseOverviewGlobData.build_external_key("BetaI"), roles.COURSE_STAFF.external_key),
],
{"AlphaU", "BetaI"},
),
)
@unpack
def test_non_staff_sees_only_assigned_orgs(self, assignments: list[tuple[str, str]], expected_orgs: set[str]):
"""Test that a non-staff user only sees the orgs covered by their role assignments.

Test cases:
- Specific ContentLibraryData scope (a single library)
- Specific CourseOverviewData scope (a single course)
- Org-level OrgContentLibraryGlobData scope (all libraries in an org)
- Org-level OrgCourseOverviewGlobData scope (all courses in an org)
- A specific CourseOverviewData scope and an OrgContentLibraryGlobData scope together,
in different orgs
- Two specific CourseOverviewData scopes, in different orgs
- Two org-level glob scopes (OrgContentLibraryGlobData and OrgCourseOverviewGlobData),
in different orgs

Expected result:
- Returns 200 OK status
- Returns only the orgs covered by the assignments
- GammaC, where the user has no assignment, is always excluded
"""
self._assign_roles_to_users(
[
{
"subject_name": "regular_1",
"role_name": role_name,
"scope_name": scope_name,
}
for scope_name, role_name in assignments
]
)
user = User.objects.get(username="regular_1")
self.client.force_authenticate(user=user)

response = self.client.get(self.url)

self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.data["count"], len(expected_orgs))
result_short_names = {org["short_name"] for org in response.data["results"]}
self.assertEqual(result_short_names, expected_orgs)

@data(
(PLATFORM_COURSE_GLOB, roles.COURSE_STAFF.external_key),
(PLATFORM_LIBRARY_GLOB, roles.LIBRARY_ADMIN.external_key),
)
@unpack
def test_non_staff_with_platform_glob_sees_all_orgs(self, scope_name: str, role_name: str):
"""Test that a platform-level glob assignment grants visibility into every active org.

Platform-level scopes (e.g. PlatformCourseOverviewGlobData) cover every org, so a user
with a role there is treated like staff/superuser for this endpoint, regardless of which
orgs they'd otherwise be able to derive from `.org` on their other assignments.

Expected result:
- Returns 200 OK status
- Returns all 3 orgs
"""
self._assign_roles_to_users(
[
{
"subject_name": "regular_1",
"role_name": role_name,
"scope_name": scope_name,
},
]
)
user = User.objects.get(username="regular_1")
self.client.force_authenticate(user=user)

response = self.client.get(self.url)

self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.data["count"], 3)


@ddt
class TestTeamMembersAPIView(ViewTestMixin):
Expand Down