Skip to content

Commit c26f972

Browse files
wgu-taylor-payneKiro
andcommitted
feat: authorize tags and taxonomies endpoints via openedx-authz when flag is enabled
Add RBAC permission checks (courses.manage_tags, courses.manage_taxonomies) to content tagging API endpoints behind AUTHZ_COURSE_AUTHORING_FLAG. Non-course-scoped taxonomy endpoints check the global flag; course-scoped object_tags endpoints check per-course. Serializer permission fields (can_change_taxonomy, can_delete_taxonomy, can_tag_object) are authz-aware so the frontend needs no changes. Co-authored-by: Kiro <[email protected]>
1 parent 027eeec commit c26f972

4 files changed

Lines changed: 354 additions & 16 deletions

File tree

openedx/core/djangoapps/content_tagging/auth.py

Lines changed: 63 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,23 +6,81 @@
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, COURSES_MANAGE_TAXONOMIES
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 _is_authz_enabled(course_key=None):
18+
"""
19+
Check if authz is enabled. When course_key is provided, checks per-course/org overrides.
20+
When course_key is None, checks the global flag state only.
21+
"""
22+
if course_key:
23+
return core_toggles.AUTHZ_COURSE_AUTHORING_FLAG.is_enabled(course_key)
24+
return core_toggles.AUTHZ_COURSE_AUTHORING_FLAG.is_enabled()
25+
26+
27+
def _user_has_authz_permission_in_any_scope(user, permission_identifier):
28+
"""
29+
Check if the user has the given authz permission in any scope.
30+
Used for non-course-scoped endpoints (e.g., taxonomy management).
31+
"""
32+
scopes = authz_api.get_scopes_for_user_and_permission(
33+
user.username, permission_identifier
34+
)
35+
return len(scopes) > 0
36+
37+
38+
def has_manage_taxonomies_access(user, course_key=None):
39+
"""
40+
Check if the user has access to manage taxonomies (create, edit, delete, import, export, manage orgs).
41+
42+
When course_key is provided, checks the flag for that specific course.
43+
When course_key is None, checks the global flag state and whether the user
44+
has the permission in any scope.
45+
Falls back to legacy taxonomy admin check when the flag is off.
46+
"""
47+
if _is_authz_enabled(course_key):
48+
if course_key:
49+
return authz_api.is_user_allowed(
50+
user.username, COURSES_MANAGE_TAXONOMIES.identifier, str(course_key)
51+
)
52+
return _user_has_authz_permission_in_any_scope(user, COURSES_MANAGE_TAXONOMIES.identifier)
53+
54+
# Legacy: taxonomy admins and org-level staff can manage taxonomies
55+
return oel_tagging_rules.is_taxonomy_admin(user)
56+
57+
58+
def has_manage_tags_access(user, course_key=None):
59+
"""
60+
Check if the user has access to manage tags (create, edit, delete, import tags on taxonomies,
61+
and tag/untag content objects).
62+
63+
When course_key is provided, checks the flag for that specific course.
64+
When course_key is None, checks the global flag state and whether the user
65+
has the permission in any scope.
66+
Falls back to legacy taxonomy admin check when the flag is off.
67+
"""
68+
if _is_authz_enabled(course_key):
69+
if course_key:
70+
return authz_api.is_user_allowed(
71+
user.username, COURSES_MANAGE_TAGS.identifier, str(course_key)
72+
)
73+
return _user_has_authz_permission_in_any_scope(user, COURSES_MANAGE_TAGS.identifier)
74+
75+
# Legacy: taxonomy admins can manage tags
76+
return oel_tagging_rules.is_taxonomy_admin(user)
77+
78+
1779
def has_view_object_tags_access(user, object_id):
1880
"""
1981
Check if the user has access to view object tags for the given object.
2082
"""
21-
# If authz is enabled, check for the export tags authz permission
2283
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.
2684
try:
2785
course_key = CourseKey.from_string(object_id)
2886
except InvalidKeyError:
@@ -33,9 +91,7 @@ def has_view_object_tags_access(user, object_id):
3391
user.username, COURSES_EXPORT_TAGS.identifier, str(course_key)
3492
)
3593

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

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

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
from organizations.models import Organization
1313
from rest_framework import fields, serializers
1414

15+
from ...auth import _is_authz_enabled, has_manage_tags_access, has_manage_taxonomies_access
1516
from ...models import TaxonomyOrg
1617

1718

@@ -65,7 +66,9 @@ def validate(self, attrs: dict) -> dict:
6566

6667
class TaxonomyOrgSerializer(TaxonomySerializer):
6768
"""
68-
Serializer for Taxonomy objects inclusing the associated orgs
69+
Serializer for Taxonomy objects including the associated orgs.
70+
71+
Overrides permission fields to be authz-aware when the feature flag is enabled.
6972
"""
7073

7174
orgs = serializers.SerializerMethodField()
@@ -89,6 +92,44 @@ def get_all_orgs(self, obj) -> bool:
8992
return True
9093
return False
9194

95+
def _get_request_user(self):
96+
"""Return the current request user, or None."""
97+
request = self.context.get('request')
98+
return request.user if request else None
99+
100+
def get_can_change(self, instance) -> bool | None:
101+
"""
102+
Override to check authz manage_taxonomies permission when the flag is globally enabled.
103+
Falls back to legacy permission check when the flag is off.
104+
"""
105+
if _is_authz_enabled():
106+
user = self._get_request_user()
107+
if user:
108+
return has_manage_taxonomies_access(user)
109+
return super().get_can_change(instance)
110+
111+
def get_can_delete(self, instance) -> bool | None:
112+
"""
113+
Override to check authz manage_taxonomies permission when the flag is globally enabled.
114+
Falls back to legacy permission check when the flag is off.
115+
"""
116+
if _is_authz_enabled():
117+
user = self._get_request_user()
118+
if user:
119+
return has_manage_taxonomies_access(user)
120+
return super().get_can_delete(instance)
121+
122+
def get_can_tag_object(self, instance) -> bool | None:
123+
"""
124+
Override to check authz manage_tags permission when the flag is globally enabled.
125+
Falls back to legacy permission check when the flag is off.
126+
"""
127+
if _is_authz_enabled():
128+
user = self._get_request_user()
129+
if user:
130+
return has_manage_tags_access(user)
131+
return super().get_can_tag_object(instance)
132+
92133
class Meta:
93134
model = TaxonomySerializer.Meta.model
94135
fields = TaxonomySerializer.Meta.fields + ["orgs", "all_orgs"]

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

Lines changed: 161 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
from edx_django_utils.cache import RequestCache
1818
from opaque_keys.edx.locator import BlockUsageLocator, CourseLocator, LibraryCollectionLocator, LibraryContainerLocator
1919
from openedx_authz.constants import permissions as authz_permissions
20-
from openedx_authz.constants.roles import COURSE_STAFF
20+
from openedx_authz.constants.roles import COURSE_ADMIN, COURSE_STAFF
2121
from openedx_tagging.models import Tag, Taxonomy
2222
from openedx_tagging.models.system_defined import SystemDefinedTaxonomy
2323
from openedx_tagging.rest_api.v1.serializers import TaxonomySerializer
@@ -2136,6 +2136,166 @@ 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)
21382138

2139+
@skip_unless_cms
2140+
class TestTaxonomyEndpointsWithAuthz(CourseAuthzTestMixin, SharedModuleStoreTestCase, APITestCase):
2141+
"""
2142+
Tests taxonomy management endpoints with openedx-authz.
2143+
2144+
When the AUTHZ_COURSE_AUTHORING_FLAG is globally enabled, taxonomy CRUD
2145+
endpoints should enforce courses.manage_taxonomies / courses.manage_tags
2146+
permissions via openedx-authz.
2147+
2148+
Uses COURSE_ADMIN role because only course_admin has manage_taxonomies permission.
2149+
"""
2150+
2151+
authz_roles_to_assign = [COURSE_ADMIN.external_key]
2152+
2153+
@classmethod
2154+
def setUpClass(cls):
2155+
super().setUpClass()
2156+
cls.password = 'test'
2157+
cls.course = CourseFactory.create()
2158+
cls.course_key = cls.course.id
2159+
cls.staff = StaffFactory(course_key=cls.course_key, password=cls.password)
2160+
2161+
def setUp(self):
2162+
super().setUp()
2163+
self.orgA = Organization.objects.create(name="Organization A", short_name="orgA")
2164+
self.taxonomy = tagging_api.create_taxonomy(name="Test Taxonomy")
2165+
tagging_api.set_taxonomy_orgs(self.taxonomy, all_orgs=False, orgs=[self.orgA])
2166+
2167+
def test_delete_taxonomy_authorized(self):
2168+
"""Authorized user can delete a taxonomy."""
2169+
url = TAXONOMY_ORG_DETAIL_URL.format(pk=self.taxonomy.id)
2170+
resp = self.authorized_client.delete(url)
2171+
self.assertEqual(resp.status_code, status.HTTP_204_NO_CONTENT)
2172+
2173+
def test_delete_taxonomy_unauthorized(self):
2174+
"""Unauthorized user cannot delete a taxonomy."""
2175+
url = TAXONOMY_ORG_DETAIL_URL.format(pk=self.taxonomy.id)
2176+
resp = self.unauthorized_client.delete(url)
2177+
self.assertEqual(resp.status_code, status.HTTP_403_FORBIDDEN)
2178+
2179+
def test_update_orgs_authorized(self):
2180+
"""Authorized user can update taxonomy orgs."""
2181+
url = TAXONOMY_ORG_UPDATE_ORG_URL.format(pk=self.taxonomy.id)
2182+
resp = self.authorized_client.put(url, {"all_orgs": True}, format="json")
2183+
self.assertEqual(resp.status_code, status.HTTP_200_OK)
2184+
2185+
def test_update_orgs_unauthorized(self):
2186+
"""Unauthorized user cannot update taxonomy orgs."""
2187+
url = TAXONOMY_ORG_UPDATE_ORG_URL.format(pk=self.taxonomy.id)
2188+
resp = self.unauthorized_client.put(url, {"all_orgs": True}, format="json")
2189+
self.assertEqual(resp.status_code, status.HTTP_403_FORBIDDEN)
2190+
2191+
def test_create_import_authorized(self):
2192+
"""Authorized user can create a taxonomy via import."""
2193+
url = TAXONOMY_CREATE_IMPORT_URL
2194+
file = SimpleUploadedFile(
2195+
"taxonomy.csv",
2196+
b"id,value\ntag_1,Tag 1\n",
2197+
content_type="text/csv",
2198+
)
2199+
resp = self.authorized_client.post(
2200+
url,
2201+
{"taxonomy_name": "Imported", "taxonomy_description": "", "file": file},
2202+
format="multipart",
2203+
)
2204+
self.assertEqual(resp.status_code, status.HTTP_201_CREATED)
2205+
2206+
def test_create_import_unauthorized(self):
2207+
"""Unauthorized user cannot create a taxonomy via import."""
2208+
url = TAXONOMY_CREATE_IMPORT_URL
2209+
file = SimpleUploadedFile(
2210+
"taxonomy.csv",
2211+
b"id,value\ntag_1,Tag 1\n",
2212+
content_type="text/csv",
2213+
)
2214+
resp = self.unauthorized_client.post(
2215+
url,
2216+
{"taxonomy_name": "Imported", "taxonomy_description": "", "file": file},
2217+
format="multipart",
2218+
)
2219+
self.assertEqual(resp.status_code, status.HTTP_403_FORBIDDEN)
2220+
2221+
def test_taxonomy_list_permission_fields_authorized(self):
2222+
"""Authorized user sees correct permission fields in taxonomy list."""
2223+
url = TAXONOMY_ORG_LIST_URL
2224+
resp = self.authorized_client.get(url)
2225+
self.assertEqual(resp.status_code, status.HTTP_200_OK)
2226+
for taxonomy in resp.data["results"]:
2227+
self.assertTrue(taxonomy["can_change_taxonomy"])
2228+
self.assertTrue(taxonomy["can_delete_taxonomy"])
2229+
self.assertTrue(taxonomy["can_tag_object"])
2230+
2231+
def test_taxonomy_list_permission_fields_unauthorized(self):
2232+
"""Unauthorized user sees restricted permission fields in taxonomy list."""
2233+
url = TAXONOMY_ORG_LIST_URL
2234+
resp = self.unauthorized_client.get(url)
2235+
self.assertEqual(resp.status_code, status.HTTP_200_OK)
2236+
for taxonomy in resp.data["results"]:
2237+
self.assertFalse(taxonomy["can_change_taxonomy"])
2238+
self.assertFalse(taxonomy["can_delete_taxonomy"])
2239+
self.assertFalse(taxonomy["can_tag_object"])
2240+
2241+
2242+
@skip_unless_cms
2243+
class TestObjectTagUpdateWithAuthz(CourseAuthzTestMixin, SharedModuleStoreTestCase, APITestCase):
2244+
"""
2245+
Tests object tag update endpoint with openedx-authz.
2246+
2247+
When the AUTHZ_COURSE_AUTHORING_FLAG is enabled for a course,
2248+
PUT /object_tags/{course_id}/ should enforce courses.manage_tags.
2249+
"""
2250+
2251+
authz_roles_to_assign = [COURSE_STAFF.external_key]
2252+
2253+
@classmethod
2254+
def setUpClass(cls):
2255+
super().setUpClass()
2256+
cls.password = 'test'
2257+
cls.course = CourseFactory.create()
2258+
cls.course_key = cls.course.id
2259+
cls.staff = StaffFactory(course_key=cls.course_key, password=cls.password)
2260+
2261+
def setUp(self):
2262+
super().setUp()
2263+
self.taxonomy = tagging_api.create_taxonomy(name="Test Taxonomy")
2264+
tagging_api.set_taxonomy_orgs(self.taxonomy, all_orgs=True, orgs=[])
2265+
Tag.objects.create(taxonomy=self.taxonomy, value="Tag 1")
2266+
2267+
def test_update_object_tags_authorized(self):
2268+
"""Authorized user can update object tags."""
2269+
url = OBJECT_TAG_UPDATE_URL.format(object_id=self.course_key)
2270+
resp = self.authorized_client.put(
2271+
url,
2272+
{"tagsData": [{"taxonomy": self.taxonomy.id, "tags": ["Tag 1"]}]},
2273+
format="json",
2274+
)
2275+
self.assertEqual(resp.status_code, status.HTTP_200_OK)
2276+
2277+
def test_update_object_tags_unauthorized(self):
2278+
"""Unauthorized user cannot update object tags."""
2279+
url = OBJECT_TAG_UPDATE_URL.format(object_id=self.course_key)
2280+
resp = self.unauthorized_client.put(
2281+
url,
2282+
{"tagsData": [{"taxonomy": self.taxonomy.id, "tags": ["Tag 1"]}]},
2283+
format="json",
2284+
)
2285+
self.assertEqual(resp.status_code, status.HTTP_403_FORBIDDEN)
2286+
2287+
def test_update_object_tags_scoped_to_course(self):
2288+
"""Authorization should only apply to the assigned course."""
2289+
other_course = self.store.create_course("OtherOrg", "OtherCourse", "Run", self.staff.id)
2290+
url = OBJECT_TAG_UPDATE_URL.format(object_id=other_course.id)
2291+
resp = self.authorized_client.put(
2292+
url,
2293+
{"tagsData": [{"taxonomy": self.taxonomy.id, "tags": ["Tag 1"]}]},
2294+
format="json",
2295+
)
2296+
self.assertEqual(resp.status_code, status.HTTP_403_FORBIDDEN)
2297+
2298+
21392299
@skip_unless_cms
21402300
@ddt.ddt
21412301
class TestDownloadTemplateView(APITestCase):

0 commit comments

Comments
 (0)