Skip to content

Commit 7ef84c8

Browse files
wgu-taylor-payneKiro
andcommitted
feat: authorize object tag updates via openedx-authz when flag is enabled
Add courses.manage_tags permission check on PUT /object_tags/{course_id}/ behind the AUTHZ_COURSE_AUTHORING_FLAG feature flag. When the flag is enabled for a course, the endpoint enforces the permission via openedx-authz. When the flag is off, the existing legacy django-rules checks are used. Co-authored-by: Kiro <[email protected]>
1 parent a761f07 commit 7ef84c8

3 files changed

Lines changed: 127 additions & 11 deletions

File tree

openedx/core/djangoapps/content_tagging/auth.py

Lines changed: 20 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,23 +6,38 @@
66
from opaque_keys import InvalidKeyError
77
from opaque_keys.edx.keys import CourseKey
88
from openedx_authz import api as authz_api
9-
from openedx_authz.constants.permissions import COURSES_EXPORT_TAGS
9+
from openedx_authz.constants.permissions import COURSES_EXPORT_TAGS, COURSES_MANAGE_TAGS
1010
from openedx_tagging import rules as oel_tagging_rules
1111

1212
from openedx.core import toggles as core_toggles
1313

1414
log = logging.getLogger(__name__)
1515

1616

17+
def has_manage_tags_access(user, course_key):
18+
"""
19+
Check if the user has access to manage tags on a course (tag/untag content objects).
20+
21+
When authz is enabled for the course, checks courses.manage_tags via openedx-authz.
22+
Otherwise falls back to the legacy permission check.
23+
"""
24+
if core_toggles.AUTHZ_COURSE_AUTHORING_FLAG.is_enabled(course_key):
25+
return authz_api.is_user_allowed(
26+
user.username, COURSES_MANAGE_TAGS.identifier, str(course_key)
27+
)
28+
29+
# Legacy: check via django-rules
30+
return user.has_perm(
31+
"oel_tagging.change_objecttag",
32+
oel_tagging_rules.ObjectTagPermissionItem(taxonomy=None, object_id=str(course_key)),
33+
)
34+
35+
1736
def has_view_object_tags_access(user, object_id):
1837
"""
1938
Check if the user has access to view object tags for the given object.
2039
"""
21-
# If authz is enabled, check for the export tags authz permission
2240
course_key = None
23-
# Try to parse the object_id as a CourseKey, if it fails,
24-
# it means object_id is not a course, so we don't validate against authz
25-
# and fallback to the legacy check.
2641
try:
2742
course_key = CourseKey.from_string(object_id)
2843
except InvalidKeyError:
@@ -33,9 +48,7 @@ def has_view_object_tags_access(user, object_id):
3348
user.username, COURSES_EXPORT_TAGS.identifier, str(course_key)
3449
)
3550

36-
# Always check for tagging permissions
3751
return user.has_perm(
3852
"oel_tagging.view_objecttag",
39-
# The obj arg expects a model, but we are passing an object
4053
oel_tagging_rules.ObjectTagPermissionItem(taxonomy=None, object_id=object_id), # type: ignore[arg-type]
4154
)

openedx/core/djangoapps/content_tagging/rest_api/v1/tests/test_views.py

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2136,6 +2136,63 @@ def test_superuser_allowed(self):
21362136
resp = client.get(self.get_url(self.course_key))
21372137
self.assertEqual(resp.status_code, status.HTTP_200_OK) # noqa: PT009
21382138

2139+
@skip_unless_cms
2140+
class TestObjectTagUpdateWithAuthz(CourseAuthzTestMixin, SharedModuleStoreTestCase, APITestCase):
2141+
"""
2142+
Tests object tag update endpoint with openedx-authz.
2143+
2144+
When the AUTHZ_COURSE_AUTHORING_FLAG is enabled for a course,
2145+
PUT /object_tags/{course_id}/ should enforce courses.manage_tags.
2146+
"""
2147+
2148+
authz_roles_to_assign = [COURSE_STAFF.external_key]
2149+
2150+
@classmethod
2151+
def setUpClass(cls):
2152+
super().setUpClass()
2153+
cls.password = 'test'
2154+
cls.course = CourseFactory.create()
2155+
cls.course_key = cls.course.id
2156+
cls.staff = StaffFactory(course_key=cls.course_key, password=cls.password)
2157+
2158+
def setUp(self):
2159+
super().setUp()
2160+
self.taxonomy = tagging_api.create_taxonomy(name="Test Taxonomy")
2161+
tagging_api.set_taxonomy_orgs(self.taxonomy, all_orgs=True, orgs=[])
2162+
Tag.objects.create(taxonomy=self.taxonomy, value="Tag 1")
2163+
2164+
def test_update_object_tags_authorized(self):
2165+
"""Authorized user can update object tags."""
2166+
url = OBJECT_TAG_UPDATE_URL.format(object_id=self.course_key)
2167+
resp = self.authorized_client.put(
2168+
url,
2169+
{"tagsData": [{"taxonomy": self.taxonomy.id, "tags": ["Tag 1"]}]},
2170+
format="json",
2171+
)
2172+
self.assertEqual(resp.status_code, status.HTTP_200_OK)
2173+
2174+
def test_update_object_tags_unauthorized(self):
2175+
"""Unauthorized user cannot update object tags."""
2176+
url = OBJECT_TAG_UPDATE_URL.format(object_id=self.course_key)
2177+
resp = self.unauthorized_client.put(
2178+
url,
2179+
{"tagsData": [{"taxonomy": self.taxonomy.id, "tags": ["Tag 1"]}]},
2180+
format="json",
2181+
)
2182+
self.assertEqual(resp.status_code, status.HTTP_403_FORBIDDEN)
2183+
2184+
def test_update_object_tags_scoped_to_course(self):
2185+
"""Authorization should only apply to the assigned course."""
2186+
other_course = self.store.create_course("OtherOrg", "OtherCourse", "Run", self.staff.id)
2187+
url = OBJECT_TAG_UPDATE_URL.format(object_id=other_course.id)
2188+
resp = self.authorized_client.put(
2189+
url,
2190+
{"tagsData": [{"taxonomy": self.taxonomy.id, "tags": ["Tag 1"]}]},
2191+
format="json",
2192+
)
2193+
self.assertEqual(resp.status_code, status.HTTP_403_FORBIDDEN)
2194+
2195+
21392196
@skip_unless_cms
21402197
@ddt.ddt
21412198
class TestDownloadTemplateView(APITestCase):

openedx/core/djangoapps/content_tagging/rest_api/v1/views.py

Lines changed: 50 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

66
from django.db.models import Count
77
from django.http import StreamingHttpResponse
8+
from opaque_keys.edx.keys import CourseKey
89
from openedx_events.content_authoring.data import ContentObjectChangedData, ContentObjectData
910
from openedx_events.content_authoring.signals import CONTENT_OBJECT_ASSOCIATIONS_CHANGED, CONTENT_OBJECT_TAGS_CHANGED
1011
from openedx_tagging import rules as oel_tagging_rules
@@ -15,6 +16,7 @@
1516
from rest_framework.response import Response
1617
from rest_framework.views import APIView
1718

19+
from openedx.core import toggles as core_toggles
1820
from openedx.core.types.http import RestRequest
1921

2022
from ...api import (
@@ -26,8 +28,9 @@
2628
get_unassigned_taxonomies,
2729
set_taxonomy_orgs,
2830
)
29-
from ...auth import has_view_object_tags_access
31+
from ...auth import has_manage_tags_access, has_view_object_tags_access
3032
from ...rules import get_admin_orgs
33+
from ...utils import get_context_key_from_key_string
3134
from .filters import ObjectTagTaxonomyOrgFilterBackend, UserOrgFilterBackend
3235
from .serializers import (
3336
ObjectTagCopiedMinimalSerializer,
@@ -154,14 +157,57 @@ class ObjectTagOrgView(ObjectTagView):
154157
minimal_serializer_class = ObjectTagCopiedMinimalSerializer
155158
filter_backends = [ObjectTagTaxonomyOrgFilterBackend]
156159

160+
def _get_course_key(self, object_id):
161+
"""
162+
Extract the course key from any content key string.
163+
Returns None if the object_id is not course-related (e.g., library content).
164+
"""
165+
try:
166+
context_key = get_context_key_from_key_string(object_id)
167+
if isinstance(context_key, CourseKey):
168+
return context_key
169+
except ValueError:
170+
pass
171+
return None
172+
173+
def retrieve(self, request, *args, **kwargs):
174+
"""
175+
Override retrieve to update can_tag_object permission fields when authz is enabled.
176+
177+
The parent serializer computes can_tag_object via legacy django-rules. When authz
178+
is enabled for the course, we override those values so the frontend correctly
179+
shows/hides tagging controls.
180+
"""
181+
response = super().retrieve(request, *args, **kwargs)
182+
object_id = kwargs.get('object_id', '')
183+
course_key = self._get_course_key(object_id)
184+
185+
if course_key and core_toggles.AUTHZ_COURSE_AUTHORING_FLAG.is_enabled(course_key):
186+
can_tag = has_manage_tags_access(request.user, course_key)
187+
for obj_data in response.data.values():
188+
for taxonomy_entry in obj_data.get("taxonomies", []):
189+
taxonomy_entry["can_tag_object"] = can_tag
190+
191+
return response
192+
157193
def update(self, request, *args, **kwargs) -> Response:
158194
"""
159-
Extend the update method to fire CONTENT_OBJECT_ASSOCIATIONS_CHANGED event
195+
Extend the update method to check authz permissions and fire events.
160196
"""
197+
object_id = kwargs.get('object_id', '')
198+
course_key = self._get_course_key(object_id)
199+
200+
# When authz is enabled for this course, enforce manage_tags permission.
201+
# This covers both course-level and block-level tagging within a course.
202+
# When authz is not enabled, fall through to the parent's legacy checks.
203+
if course_key and core_toggles.AUTHZ_COURSE_AUTHORING_FLAG.is_enabled(course_key):
204+
if not has_manage_tags_access(request.user, course_key):
205+
raise PermissionDenied(
206+
"You do not have permission to manage tags for this course."
207+
)
208+
161209
response = super().update(request, *args, **kwargs)
162210
if response.status_code == 200:
163-
object_id = kwargs.get('object_id')
164-
165211
# .. event_implemented_name: CONTENT_OBJECT_ASSOCIATIONS_CHANGED
166212
# .. event_type: org.openedx.content_authoring.content.object.associations.changed.v1
167213
CONTENT_OBJECT_ASSOCIATIONS_CHANGED.send_event(

0 commit comments

Comments
 (0)