Skip to content

Commit 55ae268

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 <kiro-noreply@amazon.com>
1 parent a761f07 commit 55ae268

3 files changed

Lines changed: 136 additions & 16 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: 62 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1979,19 +1979,19 @@ def test_get_copied_tags(self):
19791979
assert response.data[str(object_id_2)]["taxonomies"] == expected_tags
19801980

19811981
@ddt.data(
1982-
('staff', 'courseA', 8),
1982+
('staff', 'courseA', 10),
19831983
('staff', 'libraryA', 17),
19841984
('staff', 'collection_key', 17),
1985-
("content_creatorA", 'courseA', 18, False),
1985+
("content_creatorA", 'courseA', 20, False),
19861986
("content_creatorA", 'libraryA', 23, False),
19871987
("content_creatorA", 'collection_key', 23, False),
19881988
("library_staffA", 'libraryA', 23, False), # Library users can only view objecttags, not change them?
19891989
("library_staffA", 'collection_key', 23, False),
19901990
("library_userA", 'libraryA', 23, False),
19911991
("library_userA", 'collection_key', 23, False),
1992-
("instructorA", 'courseA', 18),
1993-
("course_instructorA", 'courseA', 18),
1994-
("course_staffA", 'courseA', 18),
1992+
("instructorA", 'courseA', 20),
1993+
("course_instructorA", 'courseA', 20),
1994+
("course_staffA", 'courseA', 20),
19951995
)
19961996
@ddt.unpack
19971997
def test_object_tags_query_count(
@@ -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+
assert 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+
assert 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+
assert 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: 54 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,61 @@ 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:
186+
# Use the cached flag value from the request to avoid extra DB queries
187+
# for course/org override lookups when the flag is not active.
188+
flag = core_toggles.AUTHZ_COURSE_AUTHORING_FLAG
189+
if flag.is_enabled(course_key):
190+
can_tag = has_manage_tags_access(request.user, course_key)
191+
for obj_data in response.data.values():
192+
for taxonomy_entry in obj_data.get("taxonomies", []):
193+
taxonomy_entry["can_tag_object"] = can_tag
194+
195+
return response
196+
157197
def update(self, request, *args, **kwargs) -> Response:
158198
"""
159-
Extend the update method to fire CONTENT_OBJECT_ASSOCIATIONS_CHANGED event
199+
Extend the update method to check authz permissions and fire events.
160200
"""
201+
object_id = kwargs.get('object_id', '')
202+
course_key = self._get_course_key(object_id)
203+
204+
# When authz is enabled for this course, enforce manage_tags permission.
205+
# This covers both course-level and block-level tagging within a course.
206+
# When authz is not enabled, fall through to the parent's legacy checks.
207+
if course_key and core_toggles.AUTHZ_COURSE_AUTHORING_FLAG.is_enabled(course_key):
208+
if not has_manage_tags_access(request.user, course_key):
209+
raise PermissionDenied(
210+
"You do not have permission to manage tags for this course."
211+
)
212+
161213
response = super().update(request, *args, **kwargs)
162214
if response.status_code == 200:
163-
object_id = kwargs.get('object_id')
164-
165215
# .. event_implemented_name: CONTENT_OBJECT_ASSOCIATIONS_CHANGED
166216
# .. event_type: org.openedx.content_authoring.content.object.associations.changed.v1
167217
CONTENT_OBJECT_ASSOCIATIONS_CHANGED.send_event(

0 commit comments

Comments
 (0)