Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add object tag view permissions [FC-0036] #94

40 changes: 32 additions & 8 deletions openedx_tagging/core/tagging/rest_api/v1/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@
from ...import_export.api import export_tags
from ...import_export.parsers import ParserFormat
from ...models import Taxonomy
from ...rules import ChangeObjectTagPermissionItem
from ...rules import ObjectTagPermissionItem
from ..paginators import SEARCH_TAGS_THRESHOLD, TAGS_THRESHOLD, DisabledTagsPagination, TagsPagination
from .permissions import ObjectTagObjectPermissions, TagListPermissions, TaxonomyObjectPermissions
from .serializers import (
Expand Down Expand Up @@ -298,10 +298,30 @@ def get_queryset(self) -> models.QuerySet:
data=self.request.query_params.dict()
)
query_params.is_valid(raise_exception=True)
taxonomy_id = query_params.data.get("taxonomy", None)
taxonomy = query_params.validated_data.get("taxonomy", None)
taxonomy_id = None
if taxonomy:
taxonomy = taxonomy.cast()
taxonomy_id = taxonomy.id

perm = "oel_tagging.view_objecttag"
perm_obj = ObjectTagPermissionItem(
taxonomy=taxonomy,
object_id=object_id,
)

if not self.request.user.has_perm(
perm,
# The obj arg expects a model, but we are passing an object
perm_obj, # type: ignore[arg-type]
):
raise PermissionDenied(
"You do not have permission to view object tags for this taxonomy or object_id."
)

return get_object_tags(object_id, taxonomy_id)

def retrieve(self, request, *args, **kwargs):
def retrieve(self, request, *args, **kwargs) -> Response:
"""
Retrieve ObjectTags that belong to a given object_id

Expand All @@ -312,11 +332,11 @@ def retrieve(self, request, *args, **kwargs):
path and returns a it as a single result however that is not
behavior we want.
"""
object_tags = self.get_queryset()
object_tags = self.filter_queryset(self.get_queryset())
serializer = ObjectTagSerializer(object_tags, many=True)
return Response(serializer.data)

def update(self, request, *args, **kwargs):
def update(self, request, *args, **kwargs) -> Response:
"""
Update ObjectTags that belong to a given object_id

Expand Down Expand Up @@ -352,15 +372,19 @@ def update(self, request, *args, **kwargs):
taxonomy = query_params.validated_data.get("taxonomy", None)
taxonomy = taxonomy.cast()

perm = f"{taxonomy._meta.app_label}.change_objecttag"
perm = "oel_tagging.change_objecttag"

object_id = kwargs.pop('object_id')
perm_obj = ChangeObjectTagPermissionItem(
perm_obj = ObjectTagPermissionItem(
taxonomy=taxonomy,
object_id=object_id,
)

if not request.user.has_perm(perm, perm_obj):
if not request.user.has_perm(
perm,
# The obj arg expects a model, but we are passing an object
perm_obj, # type: ignore[arg-type]
):
raise PermissionDenied(
"You do not have permission to change object tags for this taxonomy or object_id."
)
Expand Down
46 changes: 43 additions & 3 deletions openedx_tagging/core/tagging/rules.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@


@define
class ChangeObjectTagPermissionItem:
class ObjectTagPermissionItem:
"""
Pair of taxonomy and object_id used for permission checking.
"""
Expand Down Expand Up @@ -65,6 +65,44 @@ def can_change_tag(user: UserType, tag: Tag | None = None) -> bool:
)


@rules.predicate
def can_view_object_tag_objectid(_user: UserType, _object_id: str) -> bool:
"""
Everybody can view object tags from any objects.

This rule could be defined in other apps for proper permission checking.
"""
return True


@rules.predicate
def can_view_object_tag(
user: UserType, perm_obj: ObjectTagPermissionItem | None = None
) -> bool:
"""
Checks if the user has permissions to view tags on the given taxonomy and object_id.
"""

# The following code allows METHOD permission (GET) in the viewset for everyone
if perm_obj is None:
return True

# Checks the permission for the taxonomy
taxonomy_perm = user.has_perm(
"oel_tagging.view_objecttag_taxonomy", perm_obj.taxonomy
bradenmacdonald marked this conversation as resolved.
Show resolved Hide resolved
)
if not taxonomy_perm:
return False

# Checks the permission for the object_id
objectid_perm = user.has_perm(
"oel_tagging.view_objecttag_objectid",
# The obj arg expects an object, but we are passing a string
perm_obj.object_id, # type: ignore[arg-type]
)
return objectid_perm


@rules.predicate
def can_change_object_tag_objectid(_user: UserType, _object_id: str) -> bool:
"""
Expand All @@ -77,7 +115,7 @@ def can_change_object_tag_objectid(_user: UserType, _object_id: str) -> bool:

@rules.predicate
def can_change_object_tag(
user: UserType, perm_obj: ChangeObjectTagPermissionItem | None = None
user: UserType, perm_obj: ObjectTagPermissionItem | None = None
) -> bool:
"""
Checks if the user has permissions to create or modify tags on the given taxonomy and object_id.
Expand Down Expand Up @@ -122,8 +160,10 @@ def can_change_object_tag(
rules.add_perm("oel_tagging.add_objecttag", can_change_object_tag)
rules.add_perm("oel_tagging.change_objecttag", can_change_object_tag)
rules.add_perm("oel_tagging.delete_objecttag", can_change_object_tag)
rules.add_perm("oel_tagging.view_objecttag", rules.always_allow)
rules.add_perm("oel_tagging.view_objecttag", can_view_object_tag)

# Users can tag objects using tags from any taxonomy that they have permission to view
rules.add_perm("oel_tagging.view_objecttag_objectid", can_view_object_tag_objectid)
rules.add_perm("oel_tagging.view_objecttag_taxonomy", can_view_taxonomy)
rules.add_perm("oel_tagging.change_objecttag_taxonomy", can_view_taxonomy)
rules.add_perm("oel_tagging.change_objecttag_objectid", can_change_object_tag_objectid)
pomegranited marked this conversation as resolved.
Show resolved Hide resolved
8 changes: 4 additions & 4 deletions tests/openedx_tagging/core/tagging/test_rules.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
from django.test.testcases import TestCase

from openedx_tagging.core.tagging.models import ObjectTag
from openedx_tagging.core.tagging.rules import ChangeObjectTagPermissionItem
from openedx_tagging.core.tagging.rules import ObjectTagPermissionItem

from .test_models import TestTagTaxonomyMixin

Expand Down Expand Up @@ -188,7 +188,7 @@ def test_add_change_object_tag(self, perm):
"""
Everyone can create/edit an ObjectTag with an enabled Taxonomy
"""
obj_perm = ChangeObjectTagPermissionItem(
obj_perm = ObjectTagPermissionItem(
taxonomy=self.object_tag.taxonomy,
object_id=self.object_tag.object_id,
)
Expand All @@ -210,7 +210,7 @@ def test_object_tag_disabled_taxonomy(self, perm):
"""
self.taxonomy.enabled = False
self.taxonomy.save()
obj_perm = ChangeObjectTagPermissionItem(
obj_perm = ObjectTagPermissionItem(
taxonomy=self.object_tag.taxonomy,
object_id=self.object_tag.object_id,
)
Expand All @@ -229,7 +229,7 @@ def test_object_tag_without_object_permission(self, perm):
"""
self.taxonomy.enabled = False
self.taxonomy.save()
obj_perm = ChangeObjectTagPermissionItem(
obj_perm = ObjectTagPermissionItem(
taxonomy=self.object_tag.taxonomy,
object_id="not abc",
)
Expand Down
46 changes: 33 additions & 13 deletions tests/openedx_tagging/core/tagging/test_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
from openedx_tagging.core.tagging.models import ObjectTag, Tag, Taxonomy
from openedx_tagging.core.tagging.models.system_defined import SystemDefinedTaxonomy
from openedx_tagging.core.tagging.rest_api.paginators import TagsPagination
from openedx_tagging.core.tagging.rules import can_change_object_tag_objectid, can_view_object_tag_objectid

User = get_user_model()

Expand Down Expand Up @@ -506,11 +507,23 @@ class TestObjectTagViewSet(APITestCase):

def setUp(self):

def _object_permission(_user, object_id: str) -> bool:
def _change_object_permission(user, object_id: str) -> bool:
"""
Everyone have object permission on object_id "abc"
For testing, let everyone have edit object permission on object_id "abc" and "limit_tag_count"
"""
return object_id in ("abc", "limit_tag_count")
if object_id in ("abc", "limit_tag_count"):
return True

return can_change_object_tag_objectid(user, object_id)

def _view_object_permission(user, object_id: str) -> bool:
"""
For testing, let everyone have view object permission on all objects but "unauthorized_id"
"""
if object_id == "unauthorized_id":
return False

return can_view_object_tag_objectid(user, object_id)

super().setUp()

Expand Down Expand Up @@ -580,22 +593,20 @@ def _object_permission(_user, object_id: str) -> bool:
self.dummy_taxonomies.append(taxonomy)

# Override the object permission for the test
rules.set_perm("oel_tagging.change_objecttag_objectid", _object_permission)
rules.set_perm("oel_tagging.change_objecttag_objectid", _change_object_permission)
rules.set_perm("oel_tagging.view_objecttag_objectid", _view_object_permission)

@ddt.data(
(None, "abc", status.HTTP_401_UNAUTHORIZED, None),
("user", "abc", status.HTTP_200_OK, 81),
("staff", "abc", status.HTTP_200_OK, 81),
(None, "non-existing-id", status.HTTP_401_UNAUTHORIZED, None),
("user", "non-existing-id", status.HTTP_200_OK, 0),
("staff", "non-existing-id", status.HTTP_200_OK, 0),
(None, status.HTTP_401_UNAUTHORIZED, None),
("user", status.HTTP_200_OK, 81),
("staff", status.HTTP_200_OK, 81),
)
@ddt.unpack
def test_retrieve_object_tags(self, user_attr, object_id, expected_status, expected_count):
def test_retrieve_object_tags(self, user_attr, expected_status, expected_count):
"""
Test retrieving object tags
"""
url = OBJECT_TAGS_RETRIEVE_URL.format(object_id=object_id)
url = OBJECT_TAGS_RETRIEVE_URL.format(object_id="abc")

if user_attr:
user = getattr(self, user_attr)
Expand All @@ -607,6 +618,15 @@ def test_retrieve_object_tags(self, user_attr, object_id, expected_status, expec
if status.is_success(expected_status):
assert len(response.data) == expected_count

def test_retrieve_object_tags_unauthorized(self):
"""
Test retrieving object tags from an unauthorized object_id
"""
url = OBJECT_TAGS_RETRIEVE_URL.format(object_id="unauthorized_id")
self.client.force_authenticate(user=self.staff)
response = self.client.get(url)
assert response.status_code == status.HTTP_403_FORBIDDEN

@ddt.data(
(None, "abc", status.HTTP_401_UNAUTHORIZED, None),
("user", "abc", status.HTTP_200_OK, 20),
Expand Down Expand Up @@ -846,7 +866,7 @@ def test_tag_object_without_permission(self, user_attr, expected_status):
user = getattr(self, user_attr)
self.client.force_authenticate(user=user)

url = OBJECT_TAGS_UPDATE_URL.format(object_id="not abc", taxonomy_id=self.enabled_taxonomy.pk)
url = OBJECT_TAGS_UPDATE_URL.format(object_id="view_only", taxonomy_id=self.enabled_taxonomy.pk)
rpenido marked this conversation as resolved.
Show resolved Hide resolved

response = self.client.put(url, {"tags": ["Tag 1"]}, format="json")
assert response.status_code == expected_status
Expand Down
Loading