Skip to content

Commit ea2f60b

Browse files
authored
feat: add authz permission for certificates (#38190)
1 parent e416d7f commit ea2f60b

3 files changed

Lines changed: 94 additions & 27 deletions

File tree

cms/djangoapps/contentstore/rest_api/v1/views/certificates.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,14 @@
66
from rest_framework.response import Response
77
from rest_framework.views import APIView
88

9+
from openedx_authz.constants.permissions import COURSES_MANAGE_CERTIFICATES
10+
911
from cms.djangoapps.contentstore.utils import get_certificates_context
1012
from cms.djangoapps.contentstore.rest_api.v1.serializers import (
1113
CourseCertificatesSerializer,
1214
)
13-
from common.djangoapps.student.auth import has_studio_write_access
15+
from openedx.core.djangoapps.authz.constants import LegacyAuthoringPermission
16+
from openedx.core.djangoapps.authz.decorators import user_has_course_permission
1417
from openedx.core.lib.api.view_utils import (
1518
DeveloperErrorViewMixin,
1619
verify_course_exists,
@@ -96,7 +99,12 @@ def get(self, request: Request, course_id: str):
9699
course_key = CourseKey.from_string(course_id)
97100
store = modulestore()
98101

99-
if not has_studio_write_access(request.user, course_key):
102+
if not user_has_course_permission(
103+
request.user,
104+
COURSES_MANAGE_CERTIFICATES.identifier,
105+
course_key,
106+
LegacyAuthoringPermission.WRITE
107+
):
100108
self.permission_denied(request)
101109

102110
with store.bulk_operations(course_key):

cms/djangoapps/contentstore/rest_api/v1/views/tests/test_certificates.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,11 @@
44
from django.urls import reverse
55
from rest_framework import status
66

7+
from openedx_authz.constants.roles import COURSE_STAFF, COURSE_EDITOR
8+
79
from cms.djangoapps.contentstore.tests.utils import CourseTestCase
810
from cms.djangoapps.contentstore.views.tests.test_certificates import HelperMethods
11+
from openedx.core.djangoapps.authz.tests.mixins import CourseAuthoringAuthzTestMixin
912

1013
from ...mixins import PermissionAccessMixin
1114

@@ -36,3 +39,35 @@ def test_success_response(self):
3639
self.assertEqual(response_data["course_number_override"], self.course.display_coursenumber)
3740
self.assertEqual(response_data["course_title"], self.course.display_name_with_default)
3841
self.assertEqual(response_data["course_number"], self.course.number)
42+
43+
44+
class CourseCertificatesAuthzViewTest(
45+
CourseAuthoringAuthzTestMixin, CourseTestCase, PermissionAccessMixin, HelperMethods
46+
):
47+
"""
48+
Tests for CourseCertificatesView with AuthZ enabled.
49+
"""
50+
51+
def setUp(self):
52+
super().setUp()
53+
self.url = reverse(
54+
"cms.djangoapps.contentstore:v1:certificates",
55+
kwargs={"course_id": self.course.id},
56+
)
57+
58+
def test_authorized_user_can_access(self):
59+
"""User with COURSE_STAFF role can access."""
60+
self._add_course_certificates(count=2, signatory_count=2)
61+
self.add_user_to_role_in_course(self.authorized_user, COURSE_STAFF.external_key, self.course.id)
62+
resp = self.authorized_client.get(self.url)
63+
self.assertEqual(resp.status_code, status.HTTP_200_OK)
64+
65+
def test_non_staff_user_cannot_access(self):
66+
"""
67+
User without permissions should be denied.
68+
This case validates that a non-staff user cannot access.
69+
"""
70+
self._add_course_certificates(count=2, signatory_count=2)
71+
self.add_user_to_role_in_course(self.authorized_user, COURSE_EDITOR.external_key, self.course.id)
72+
resp = self.authorized_client.get(self.url)
73+
self.assertEqual(resp.status_code, status.HTTP_403_FORBIDDEN)

openedx/core/djangoapps/authz/tests/mixins.py

Lines changed: 49 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -14,22 +14,25 @@
1414
from common.djangoapps.student.tests.factories import UserFactory
1515

1616

17-
class CourseAuthzTestMixin:
18-
"""
19-
Reusable mixin for testing course-scoped AuthZ endpoints.
17+
class CourseAuthoringAuthzTestMixin:
2018
"""
19+
Base mixin for testing AuthZ in the course authoring context.
2120
22-
authz_roles_to_assign = [COURSE_STAFF.external_key]
21+
Responsibilities:
22+
- Enable course authoring AuthZ feature flag
23+
- Seed policies into the AuthZ enforcer
24+
- Provide authenticated test clients
25+
- Provide helpers for assigning roles within a course scope
26+
"""
2327

2428
@classmethod
2529
def setUpClass(cls):
2630
cls.toggle_patcher = patch.object(
2731
core_toggles.AUTHZ_COURSE_AUTHORING_FLAG,
2832
"is_enabled",
29-
return_value=True
33+
return_value=True,
3034
)
3135
cls.toggle_patcher.start()
32-
3336
super().setUpClass()
3437

3538
@classmethod
@@ -40,56 +43,77 @@ def tearDownClass(cls):
4043
def setUp(self):
4144
super().setUp()
4245

43-
self._seed_database_with_policies()
46+
self._seed_policies()
4447

4548
self.authorized_user = UserFactory()
4649
self.unauthorized_user = UserFactory()
4750

48-
for role in self.authz_roles_to_assign:
49-
assign_role_to_user_in_scope(
50-
self.authorized_user.username,
51-
role,
52-
str(self.course_key)
53-
)
54-
55-
AuthzEnforcer.get_enforcer().load_policy()
56-
5751
self.authorized_client = APIClient()
5852
self.authorized_client.force_authenticate(user=self.authorized_user)
5953

6054
self.unauthorized_client = APIClient()
6155
self.unauthorized_client.force_authenticate(user=self.unauthorized_user)
6256

63-
def add_user_to_role(self, user, role):
57+
def tearDown(self):
58+
super().tearDown()
59+
AuthzEnforcer.get_enforcer().clear_policy()
60+
61+
def add_user_to_role_in_course(self, user, role, course_key):
6462
"""Helper method to add a user to a role for the course."""
6563
assign_role_to_user_in_scope(
6664
user.username,
6765
role,
68-
str(self.course_key)
66+
str(course_key)
6967
)
7068
AuthzEnforcer.get_enforcer().load_policy()
7169

72-
def tearDown(self):
73-
super().tearDown()
74-
AuthzEnforcer.get_enforcer().clear_policy()
75-
7670
@classmethod
77-
def _seed_database_with_policies(cls):
71+
def _seed_policies(cls):
7872
"""Seed the database with AuthZ policies."""
7973
global_enforcer = AuthzEnforcer.get_enforcer()
8074
global_enforcer.load_policy()
8175

8276
model_path = pkg_resources.resource_filename(
8377
"openedx_authz.engine",
84-
"config/model.conf"
78+
"config/model.conf",
8579
)
8680

8781
policy_path = pkg_resources.resource_filename(
8882
"openedx_authz.engine",
89-
"config/authz.policy"
83+
"config/authz.policy",
9084
)
9185

9286
migrate_policy_between_enforcers(
9387
source_enforcer=casbin.Enforcer(model_path, policy_path),
9488
target_enforcer=global_enforcer,
9589
)
90+
91+
92+
class CourseAuthzTestMixin(CourseAuthoringAuthzTestMixin):
93+
"""
94+
Reusable mixin for testing course-scoped AuthZ endpoints.
95+
"""
96+
97+
authz_roles_to_assign = [COURSE_STAFF.external_key]
98+
99+
@property
100+
def course_key(self):
101+
"""
102+
Must be defined by subclasses.
103+
"""
104+
raise NotImplementedError("Tests using CourseAuthzTestMixin must define 'course_key'")
105+
106+
def setUp(self):
107+
super().setUp()
108+
for role in self.authz_roles_to_assign:
109+
assign_role_to_user_in_scope(
110+
self.authorized_user.username,
111+
role,
112+
str(self.course_key)
113+
)
114+
115+
AuthzEnforcer.get_enforcer().load_policy()
116+
117+
def add_user_to_role(self, user, role):
118+
"""Helper method to add a user to a role for the course."""
119+
self.add_user_to_role_in_course(user, role, self.course_key)

0 commit comments

Comments
 (0)