Skip to content

Commit 69b10ac

Browse files
committed
test: add more test for mixed scope
1 parent 96a74e8 commit 69b10ac

2 files changed

Lines changed: 152 additions & 8 deletions

File tree

openedx_authz/tests/rest_api/test_permissions.py

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,12 @@
22

33
from unittest.mock import MagicMock, patch
44

5+
import ddt
56
from django.test import TestCase
67

78
from openedx_authz.rest_api.v1.permissions import (
89
BaseScopePermission,
10+
ContentLibraryPermission,
911
CoursePermission,
1012
DynamicScopePermission,
1113
)
@@ -114,6 +116,58 @@ def test_one_scope_fails_returns_false(self, _):
114116
self.assertFalse(self.perm.has_permission(request, _make_view(method="get", required_permissions=["p"])))
115117

116118

119+
@ddt.ddt
120+
class TestDynamicScopePermissionBulkScopesMixed(TestCase):
121+
"""Test DynamicScopePermission bulk-scopes behaviour when mixing specific and org-level scopes.
122+
123+
Parameterized over lib (lib:Org:A / lib:Org:*) and course-v1 (course-v1:Org1+C1+2024 / course-v1:Org1+*)
124+
namespaces to verify that the AND-logic holds regardless of whether a scope targets a specific
125+
resource or an entire org.
126+
"""
127+
128+
def setUp(self):
129+
self.perm = DynamicScopePermission()
130+
131+
def test_mixed_namespaces_raises_value_error(self):
132+
"""Mixing lib and course-v1 scopes in the same bulk request raises ValueError."""
133+
request = _make_request(data={"scopes": ["lib:Org:A", "course-v1:Org1+C1+2024"]})
134+
with self.assertRaises(ValueError):
135+
self.perm.has_permission(request, _make_view(required_permissions=["p"]))
136+
137+
@ddt.data(
138+
(["lib:Org:A", "lib:Org:*"], "get"),
139+
(["course-v1:Org1+C1+2024", "course-v1:Org1+*"], "get"),
140+
)
141+
@ddt.unpack
142+
@patch("openedx_authz.api.is_user_allowed", return_value=True)
143+
def test_specific_and_org_scope_both_pass_returns_true(self, scopes, method, _):
144+
"""When the user has permission on both the specific scope and the org-level scope, access is granted."""
145+
request = _make_request(data={"scopes": scopes}, method=method.upper())
146+
self.assertTrue(self.perm.has_permission(request, _make_view(method=method, required_permissions=["p"])))
147+
148+
@ddt.data(
149+
(["lib:Org:A", "lib:Org:*"], "get"),
150+
(["course-v1:Org1+C1+2024", "course-v1:Org1+*"], "get"),
151+
)
152+
@ddt.unpack
153+
@patch("openedx_authz.api.is_user_allowed", side_effect=[True, False])
154+
def test_specific_passes_org_fails_returns_false(self, scopes, method, _):
155+
"""When the user has permission on the specific scope but not the org-level scope, access is denied."""
156+
request = _make_request(data={"scopes": scopes}, method=method.upper())
157+
self.assertFalse(self.perm.has_permission(request, _make_view(method=method, required_permissions=["p"])))
158+
159+
@ddt.data(
160+
(["lib:Org:A", "lib:Org:*"], "get"),
161+
(["course-v1:Org1+C1+2024", "course-v1:Org1+*"], "get"),
162+
)
163+
@ddt.unpack
164+
@patch("openedx_authz.api.is_user_allowed", side_effect=[False, True])
165+
def test_specific_fails_org_passes_returns_false(self, scopes, method, _):
166+
"""When the user has permission on the org-level scope but not the specific scope, access is denied."""
167+
request = _make_request(data={"scopes": scopes}, method=method.upper())
168+
self.assertFalse(self.perm.has_permission(request, _make_view(method=method, required_permissions=["p"])))
169+
170+
117171
class TestCoursePermission(TestCase):
118172
"""Test CoursePermission class."""
119173

@@ -141,3 +195,56 @@ def test_scope_with_permission_denied(self, _):
141195
"""When the user lacks the required permission on the course scope, access is denied."""
142196
request = _make_request(data={"scope": "course-v1:Org1+C1+2024"}, method="GET")
143197
self.assertFalse(self.perm.has_permission(request, _make_view(method="get", required_permissions=["p"])))
198+
199+
@patch("openedx_authz.api.is_user_allowed", return_value=True)
200+
def test_org_scope_allowed(self, _):
201+
"""An org-level course scope ('course-v1:Org1+*') grants access when the user has the required permission."""
202+
request = _make_request(data={"scope": "course-v1:Org1+*"}, method="GET")
203+
self.assertTrue(self.perm.has_permission(request, _make_view(method="get", required_permissions=["p"])))
204+
205+
@patch("openedx_authz.api.is_user_allowed", return_value=False)
206+
def test_org_scope_denied(self, _):
207+
"""An org-level course scope ('course-v1:Org1+*') denies access when the user lacks the required permission."""
208+
request = _make_request(data={"scope": "course-v1:Org1+*"}, method="GET")
209+
self.assertFalse(self.perm.has_permission(request, _make_view(method="get", required_permissions=["p"])))
210+
211+
212+
class TestContentLibraryPermission(TestCase):
213+
"""Test ContentLibraryPermission class."""
214+
215+
def setUp(self):
216+
self.perm = ContentLibraryPermission()
217+
218+
def test_no_scope_returns_false(self):
219+
"""A request without any scope value is always rejected — there is nothing to authorize against."""
220+
self.assertFalse(self.perm.has_permission(_make_request(), _make_view(required_permissions=["p"])))
221+
222+
def test_scope_no_decorator_returns_true(self):
223+
"""When a scope is present but the view method has no @authz_permissions decorator,
224+
the endpoint is considered open and access is granted."""
225+
request = _make_request(data={"scope": "lib:Org1:A"})
226+
self.assertTrue(self.perm.has_permission(request, _make_view(required_permissions=None)))
227+
228+
@patch("openedx_authz.api.is_user_allowed", return_value=True)
229+
def test_scope_with_permission_allowed(self, _):
230+
"""When the user has the required permission on the given library scope, access is granted."""
231+
request = _make_request(data={"scope": "lib:Org1:A"}, method="GET")
232+
self.assertTrue(self.perm.has_permission(request, _make_view(method="get", required_permissions=["p"])))
233+
234+
@patch("openedx_authz.api.is_user_allowed", return_value=False)
235+
def test_scope_with_permission_denied(self, _):
236+
"""When the user lacks the required permission on the library scope, access is denied."""
237+
request = _make_request(data={"scope": "lib:Org1:A"}, method="GET")
238+
self.assertFalse(self.perm.has_permission(request, _make_view(method="get", required_permissions=["p"])))
239+
240+
@patch("openedx_authz.api.is_user_allowed", return_value=True)
241+
def test_org_scope_allowed(self, _):
242+
"""An org-level lib scope ('lib:Org1:*') grants access when the user has the required permission."""
243+
request = _make_request(data={"scope": "lib:Org1:*"}, method="GET")
244+
self.assertTrue(self.perm.has_permission(request, _make_view(method="get", required_permissions=["p"])))
245+
246+
@patch("openedx_authz.api.is_user_allowed", return_value=False)
247+
def test_org_scope_denied(self, _):
248+
"""An org-level lib scope ('lib:Org1:*') denies access when the user lacks the required permission."""
249+
request = _make_request(data={"scope": "lib:Org1:*"}, method="GET")
250+
self.assertFalse(self.perm.has_permission(request, _make_view(method="get", required_permissions=["p"])))

openedx_authz/tests/rest_api/test_views.py

Lines changed: 45 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717

1818
from openedx_authz import api
1919
from openedx_authz.api.data import (
20+
CourseOverviewData,
2021
OrgContentLibraryGlobData,
2122
OrgCourseOverviewGlobData,
2223
)
@@ -35,6 +36,7 @@
3536
User = get_user_model()
3637

3738
COURSE_SCOPE_ORG1 = "course-v1:Org1+COURSE1+2024"
39+
COURSE_ORG1_GLOB = OrgCourseOverviewGlobData.build_external_key(CourseOverviewData(external_key=COURSE_SCOPE_ORG1).org)
3840

3941

4042
class ViewTestMixin(BaseRolesTestCase):
@@ -146,7 +148,7 @@ def create_admin_users(cls, quantity: int):
146148
@classmethod
147149
def create_course_users(cls):
148150
"""Create course users (plain, non-staff)."""
149-
users = ["course_admin", "course_editor", "course_auditor"]
151+
users = ["course_admin", "course_editor", "course_auditor", "course_admin_org"]
150152
for username in users:
151153
User.objects.get_or_create(
152154
username=username, defaults={"email": f"{username}@example.com"}
@@ -1521,7 +1523,7 @@ def test_org_glob_scope_returns_all_org_courses(self):
15211523
self.build_qs_patcher.stop()
15221524

15231525
# Simulate get_scopes_for_user_and_permission returning an org-level glob.
1524-
glob_scope = OrgCourseOverviewGlobData(external_key="course-v1:Org1+*")
1526+
glob_scope = OrgCourseOverviewGlobData(external_key=COURSE_ORG1_GLOB)
15251527
with patch(
15261528
"openedx_authz.rest_api.v1.views.get_scopes_for_user_and_permission",
15271529
return_value=[glob_scope],
@@ -3908,7 +3910,7 @@ def test_user_with_org_course_permission_sees_org_course_assignments(self):
39083910
{
39093911
"subject_name": "regular_10",
39103912
"role_name": roles.COURSE_STAFF.external_key,
3911-
"scope_name": "course-v1:Org1+*",
3913+
"scope_name": COURSE_ORG1_GLOB,
39123914
},
39133915
]
39143916
)
@@ -4028,24 +4030,31 @@ def test_user_with_both_library_and_course_permissions(self):
40284030
self.assertIn("course-v1", scope_types)
40294031

40304032

4033+
@ddt
40314034
class TestBulkPutScopesAllLogic(ViewTestMixin):
40324035
"""Test that DynamicScopePermission enforces AND logic across scopes in bulk PUT.
40334036
40344037
validate_permissions uses OR logic (any permission suffices per scope), but
40354038
DynamicScopePermission wraps that with all(...for sv in scopes_list), meaning
40364039
the user must pass the permission check for EVERY scope in the list.
40374040
4038-
course_admin has COURSE_ADMIN on COURSE_SCOPE_ORG1 only, giving them
4039-
COURSES_MANAGE_COURSE_TEAM on that scope but no permissions elsewhere.
4041+
Two users illustrate the difference between specific-scope and org-level permissions:
4042+
- course_admin: COURSE_ADMIN on COURSE_SCOPE_ORG1 only (specific course).
4043+
- course_admin_org: COURSE_ADMIN on COURSE_ORG1_GLOB (all courses in Org1).
40404044
"""
40414045

4042-
ANOTHER_COURSE_SCOPE = "course-v1:Org1+COURSE2+2024"
4046+
ANOTHER_COURSE_SCOPE = "course-v1:Org2+COURSE2+2024"
40434047
_COURSE_ASSIGNMENTS = [
40444048
{
40454049
"subject_name": "course_admin",
40464050
"role_name": roles.COURSE_ADMIN.external_key,
40474051
"scope_name": COURSE_SCOPE_ORG1,
40484052
},
4053+
{
4054+
"subject_name": "course_admin_org",
4055+
"role_name": roles.COURSE_ADMIN.external_key,
4056+
"scope_name": COURSE_ORG1_GLOB,
4057+
},
40494058
]
40504059

40514060
def setUp(self):
@@ -4057,7 +4066,8 @@ def setUp(self):
40574066

40584067
def _put_course(self, scopes):
40594068
request_data = {"role": roles.COURSE_ADMIN.external_key, "scopes": scopes, "users": ["regular_2"]}
4060-
with patch.object(api.CourseOverviewData, "exists", return_value=True):
4069+
with patch.object(api.CourseOverviewData, "exists", return_value=True), \
4070+
patch.object(api.OrgCourseOverviewGlobData, "exists", return_value=True):
40614071
return self.client.put(self.url, data=request_data, format="json")
40624072

40634073
def _put_lib(self, scopes):
@@ -4066,7 +4076,7 @@ def _put_lib(self, scopes):
40664076
return self.client.put(self.url, data=request_data, format="json")
40674077

40684078
def test_all_scopes_permitted_succeeds(self):
4069-
"""User has permission on all requested scopes → 207."""
4079+
"""course_admin has permission on their specific scope → 207."""
40704080
response = self._put_course([COURSE_SCOPE_ORG1])
40714081
self.assertEqual(response.status_code, status.HTTP_207_MULTI_STATUS)
40724082

@@ -4088,3 +4098,30 @@ def test_course_user_cannot_add_library_roles(self):
40884098
"""
40894099
response = self._put_lib(["lib:Org1:LIB1"])
40904100
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
4101+
4102+
@data(
4103+
# course_admin has COURSE_ADMIN on the specific course only.
4104+
# Passes for the exact scope, but fails for the org-level glob or any combo including it.
4105+
("course_admin", [COURSE_SCOPE_ORG1], status.HTTP_207_MULTI_STATUS),
4106+
("course_admin", [COURSE_ORG1_GLOB], status.HTTP_403_FORBIDDEN),
4107+
("course_admin", [COURSE_SCOPE_ORG1, COURSE_ORG1_GLOB], status.HTTP_403_FORBIDDEN),
4108+
# course_admin_org has COURSE_ADMIN on the org-level glob.
4109+
# Via Casbin glob matching, this covers both the glob itself and any specific course within the org.
4110+
("course_admin_org", [COURSE_SCOPE_ORG1], status.HTTP_207_MULTI_STATUS),
4111+
("course_admin_org", [COURSE_ORG1_GLOB], status.HTTP_207_MULTI_STATUS),
4112+
("course_admin_org", [COURSE_SCOPE_ORG1, COURSE_ORG1_GLOB], status.HTTP_207_MULTI_STATUS),
4113+
)
4114+
@unpack
4115+
def test_scope_permission_vs_org_permission(self, username, scopes, expected_status):
4116+
"""A user with a specific-scope role and one with an org-level role behave differently
4117+
when bulk PUT includes the org-level glob alongside specific scopes.
4118+
4119+
course_admin (specific scope) fails on any scope list that includes the org glob,
4120+
because they have no permission at that level.
4121+
course_admin_org (org-level glob) passes for both the glob and any specific course
4122+
within that org, thanks to Casbin's glob matching.
4123+
"""
4124+
user = User.objects.get(username=username)
4125+
self.client.force_authenticate(user=user)
4126+
response = self._put_course(scopes)
4127+
self.assertEqual(response.status_code, expected_status)

0 commit comments

Comments
 (0)