Skip to content

Commit

Permalink
Tagging: serialize object permissions to REST API [FC-0036] (#138)
Browse files Browse the repository at this point in the history
* feat: adds can_<action>_<model> fields to the tagging REST API results using a custom serializer
* feat: adds can_add_<model> field above the results using a custom paginator
* refactor: replaces ObjectTagsByTaxonomySerializer.editable with "can_tag_object" rule: used by the Content Tag Drawer and Taxonomy UIs
* tests: verified that adding these permissions doesn't affect the query count.
* chore: bumps version to 0.4.4
  • Loading branch information
pomegranited authored Jan 24, 2024
1 parent 946336e commit 2508f13
Show file tree
Hide file tree
Showing 8 changed files with 495 additions and 100 deletions.
2 changes: 1 addition & 1 deletion openedx_learning/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
"""
Open edX Learning ("Learning Core").
"""
__version__ = "0.4.3"
__version__ = "0.4.4"
63 changes: 61 additions & 2 deletions openedx_tagging/core/tagging/rest_api/paginators.py
Original file line number Diff line number Diff line change
@@ -1,23 +1,75 @@
"""
Paginators uses by the REST API
"""
from typing import Type

from edx_rest_framework_extensions.paginators import DefaultPagination # type: ignore[import]
from rest_framework.request import Request
from rest_framework.response import Response

from openedx_tagging.core.tagging.models import Tag, Taxonomy

from .utils import UserPermissionsHelper

# From this point, the tags begin to be paginated
MAX_FULL_DEPTH_THRESHOLD = 10_000


class TagsPagination(DefaultPagination):
class CanAddPermissionMixin(UserPermissionsHelper): # pylint: disable=abstract-method
"""
This mixin inserts a boolean "can_add_<model>" field at the top level of the paginated response.
The value of the field indicates whether request user may create new instances of the current model.
"""
@property
def _request(self) -> Request:
"""
Returns the current request.
"""
return self.request # type: ignore[attr-defined]

def get_paginated_response(self, data) -> Response:
"""
Injects the user's model-level permissions into the paginated response.
"""
response_data = super().get_paginated_response(data).data # type: ignore[misc]
field_name = f"can_add_{self.model_name}"
response_data[field_name] = self.get_can_add()
return Response(response_data)


class TaxonomyPagination(CanAddPermissionMixin, DefaultPagination):
"""
Inserts permissions data for Taxonomies into the top level of the paginated response.
"""
page_size = 500
max_page_size = 500

@property
def _model(self) -> Type:
"""
Returns the model that is being paginated.
"""
return Taxonomy


class TagsPagination(CanAddPermissionMixin, DefaultPagination):
"""
Custom pagination configuration for taxonomies
with a large number of tags. Used on the get tags API view.
"""
page_size = 10
max_page_size = 300

@property
def _model(self) -> Type:
"""
Returns the model that is being paginated.
"""
return Tag


class DisabledTagsPagination(DefaultPagination):
class DisabledTagsPagination(CanAddPermissionMixin, DefaultPagination):
"""
Custom pagination configuration for taxonomies
with a small number of tags. Used on the get tags API view
Expand All @@ -28,3 +80,10 @@ class DisabledTagsPagination(DefaultPagination):
"""
page_size = MAX_FULL_DEPTH_THRESHOLD
max_page_size = MAX_FULL_DEPTH_THRESHOLD + 1

@property
def _model(self) -> Type:
"""
Returns the model that is being paginated.
"""
return Tag
109 changes: 109 additions & 0 deletions openedx_tagging/core/tagging/rest_api/utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
"""
Utilities for the API
"""
from typing import Optional, Type

from edx_rest_framework_extensions.auth.jwt.authentication import JwtAuthentication # type: ignore[import]
from edx_rest_framework_extensions.auth.session.authentication import ( # type: ignore[import]
SessionAuthenticationAllowInactiveUser,
)
from rest_framework.request import Request


def view_auth_classes(func_or_class):
"""
Function and class decorator that abstracts the authentication classes for api views.
"""
def _decorator(func_or_class):
"""
Requires either OAuth2 or Session-based authentication;
are the same authentication classes used on edx-platform
"""
func_or_class.authentication_classes = (
JwtAuthentication,
SessionAuthenticationAllowInactiveUser,
)
return func_or_class
return _decorator(func_or_class)


class UserPermissionsHelper:
"""
Provides helper methods for serializing user permissions.
"""
@property
def _request(self) -> Request:
"""
Returns the current request.
"""
raise NotImplementedError # pragma: no cover

@property
def _model(self) -> Type:
"""
Returns the model used when checking permissions.
"""
raise NotImplementedError # pragma: no cover

@property
def app_label(self) -> Type:
"""
Returns the app_label for the model used when checking permissions.
"""
return self._model._meta.app_label

@property
def model_name(self) -> Type:
"""
Returns the name of the model used when checking permissions.
"""
return self._model._meta.model_name

def _get_permission_name(self, action: str) -> str:
"""
Returns the fully-qualified permission name corresponding to the current model and `action`.
"""
assert action in ("add", "view", "change", "delete")
return f'{self.app_label}.{action}_{self.model_name}'

def _can(self, perm_name: str, instance=None) -> Optional[bool]:
"""
Does the current `request.user` have the given `perm` on the `instance` object?
Returns None if no permissions were requested.
Returns True if they may.
Returns False if they may not.
"""
request = self._request
assert request and request.user
return request.user.has_perm(perm_name, instance)

def get_can_add(self, _instance=None) -> Optional[bool]:
"""
Returns True if the current user is allowed to add new instances.
Note: we omit the actual instance from the permissions check; most tagging models prefer this.
"""
perm_name = self._get_permission_name('add')
return self._can(perm_name)

def get_can_view(self, instance) -> Optional[bool]:
"""
Returns True if the current user is allowed to view/see this instance.
"""
perm_name = self._get_permission_name('view')
return self._can(perm_name, instance)

def get_can_change(self, instance) -> Optional[bool]:
"""
Returns True if the current user is allowed to edit/change this instance.
"""
perm_name = self._get_permission_name('change')
return self._can(perm_name, instance)

def get_can_delete(self, instance) -> Optional[bool]:
"""
Returns True if the current user is allowed to delete this instance.
"""
perm_name = self._get_permission_name('change')
return self._can(perm_name, instance)
Loading

0 comments on commit 2508f13

Please sign in to comment.