From d1b5a302c167655e4e6f044103d0abbf97685800 Mon Sep 17 00:00:00 2001 From: Luis Saavedra Date: Sun, 27 Oct 2024 17:17:33 -0300 Subject: [PATCH] first docs --- MANIFEST.in | 2 +- README.md | 22 --- README.rst | 332 +++++++++++++++++++++++++++++++++++ pyproject.toml | 13 +- src/drf_rules/permissions.py | 22 ++- tests/testapp/models.py | 4 +- tests/testapp/tests.py | 86 +++++++-- 7 files changed, 433 insertions(+), 48 deletions(-) delete mode 100644 README.md create mode 100644 README.rst diff --git a/MANIFEST.in b/MANIFEST.in index a7497d8..cb421d3 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,7 +1,7 @@ include CHANGELOG.md include INSTALL include LICENSE -include README.md +include README.rst include runtests.sh recursive-include tests * global-exclude *.py[cod] __pycache__ diff --git a/README.md b/README.md deleted file mode 100644 index 4a7908f..0000000 --- a/README.md +++ /dev/null @@ -1,22 +0,0 @@ -# drf rules - -[![PyPI - Version](https://img.shields.io/pypi/v/drf-rules.svg)](https://pypi.org/project/drf-rules) -[![PyPI - Python Version](https://img.shields.io/pypi/pyversions/drf-rules.svg)](https://pypi.org/project/drf-rules) -[![Coverage Status](https://coveralls.io/repos/github/lsaavedr/drf-rules/badge.svg)](https://coveralls.io/github/lsaavedr/drf-rules) - ---- - -## Table of Contents - -- [Installation](#installation) -- [License](#license) - -## Installation - -```console -pip install drf-rules -``` - -## License - -`drf-rules` is distributed under the terms of the [BSD-3-Clause](https://spdx.org/licenses/BSD-3-Clause.html) license. diff --git a/README.rst b/README.rst new file mode 100644 index 0000000..ce5da15 --- /dev/null +++ b/README.rst @@ -0,0 +1,332 @@ +drf-rules +========= + +.. image:: https://img.shields.io/pypi/v/drf-rules.svg + :target: https://pypi.org/project/drf-rules + :alt: PyPI - Version + +.. image:: https://img.shields.io/pypi/pyversions/drf-rules.svg + :target: https://pypi.org/project/drf-rules + :alt: PyPI - Python Version + +.. image:: https://coveralls.io/repos/github/lsaavedr/drf-rules/badge.svg + :target: https://coveralls.io/github/lsaavedr/drf-rules + :alt: Coverage Status + +``drf-rules`` is a Django Rest Framework library that provides object-level +permissions based on rules. It allows you to define fine-grained access +control for your API endpoints, enabling you to specify which users or groups +can perform certain actions on specific objects. + +---- + +.. _django-rules: https://github.com/dfunckt/django-rules + + +Features +-------- + +- **KISS Principle**: The library follows the KISS principle, providing a + simple and easy-to-understand how it works. +- **Documented**: The library is well-documented, with clear examples and + explanations of how to use its features. +- **Tested**: The library is thoroughly tested, with a high test coverage to + ensure its reliability and correctness. +- **DRF Integration**: Seamlessly integrates with Django Rest Framework to + provide object-level permissions. +- **Based on django-rules**: Built on top of the `django-rules`_ library, + which provides a flexible and extensible rule system. + + +Table of Contents +----------------- + +- `Requirements`_ +- `Installation`_ +- `Configuring Django`_ +- `Defining Rules`_ +- `Using Rules with DRF`_ + + + `Permissions in models`_ + + `Permissions in views`_ +- `License`_ + + +Requirements +------------ + +``drf-rules`` requires Python 3.8 or newer and Django 3.2 or newer. + +Note: At any given moment in time, `drf-rules` will maintain support for all +currently supported Django versions, while dropping support for those versions +that reached end-of-life in minor releases. See the Supported Versions section +on Django Project website for the current state and timeline. + + +Installation +------------ + +Using pip: + +.. code-block:: bash + + $ pip install drf-rules + +Run test with: + +.. code-block:: bash + + $ ./runtests.sh + + +.. _`Configuring Django`: + +Configuring Django (see `django-rules`_) +---------------------------------------- + +Add ``rules`` to ``INSTALLED_APPS``: + +.. code:: python + + INSTALLED_APPS = ( + # ... + 'rules', + ) + +Add the authentication backend: + +.. code:: python + + AUTHENTICATION_BACKENDS = ( + 'rules.permissions.ObjectPermissionBackend', + 'django.contrib.auth.backends.ModelBackend', + ) + + +.. _`Defining Rules`: + +Defining Rules (see `django-rules`_) +------------------------------------ + +For a comprehensive guide on using `django-rules`_, please refer to the +detailed documentation. + +We will suppose that you have a ``Book`` model and you want to restrict access +to it based on the user's group. + +First, define the rule in a ``rules.py`` file: + + +.. code:: python + + import rules + + # Define a rule that checks if the user's group is 'librarians' + @rules.predicate + def is_librarian(user): + return user.groups.filter(name='librarians').exists() + + # Define a rule that checks if the user's group is 'authors' + @rules.predicate + def is_author(user): + return user.groups.filter(name='authors').exists() + + # Define a rule that checks if the user's group is 'managers' + @rules.predicate + def is_manager(user): + return user.groups.filter(name='managers').exists() + + # Define a rule that checks if the user is the author of the book + @rules.predicate + def is_book_author(user, book): + return book.author == user + + +.. _`Using Rules with DRF`: + +Using Rules with DRF (see `django-rules`_) +------------------------------------------ + +We will assume that you have already defined all the necessary rules to +restrict access to your API. + +The ``rules`` library is capable of providing object-level permissions in +Django. It includes an authorization backend and several template tags for use +in your templates. You will need to utilize this library to implement all the +required rules. + + +Permissions in models ++++++++++++++++++++++ + +It is common to have a set of permissions for a model, similar to what Django +provides with its default model permissions (such as *add*, *change*, etc.). +When using ``rules`` as the permission checking backend, you can declare +object-level permissions for any model in a similar manner, using a new +``Meta`` option. + +To integrate the rules library with your Django models, you'll need to switch +your model's base class and metaclass to the extended versions provided in +``rules.contrib.models``. The extensions are lightweight and only augment the +models by registering permissions. They do not create any migrations for your +models. + +The approach you take depends on whether you're using a custom base class +and/or metaclass for your models. Here are the steps: + +* If you're using the stock ``django.db.models.Model`` as base for your models, + simply switch over to ``RulesModel`` and you're good to go. +* If you're currently using the default ``django.db.models.Model`` as the base + for your models, simply switch to using ``RulesModel`` instead, and you're + all set. +* If you already have a custom base class that adds common functionality to + your models, you can integrate ``RulesModelMixin`` and set ``RulesModelBase`` + as the metaclass. Here's how you can do it: + + .. code:: python + + from django.db.models import Model + from rules.contrib.models import RulesModelBase, RulesModelMixin + + class MyModel(RulesModelMixin, Model, metaclass=RulesModelBase): + ... + +* If you're using a custom metaclass for your models, you'll know how to + ensure it inherits from ``RulesModelBaseMixin``. + + To create your models, assuming you are using ``RulesModel`` as the base + class directly, follow this example: + + .. code:: python + + import rules + from rules.contrib.models import RulesModel + + class Book(RulesModel): + class Meta: + rules_permissions = { + "create": rules.is_staff, + "retrieve": rules.is_authenticated, + } + + The ``RulesModelMixin`` includes methods that you can override to customize + how a model's permissions are registered. For more details, refer to the + `django-rules `_ documentation. + + +**NOTE:** The keys of ``rules_permissions`` differ from Django's default name +conventions (which are also used by ``django-rules``). Instead, we adopt the +Django Rest Framework (DRF) conventions. Below is a table showing the default +CRUD keys for both conventions: + +.. list-table:: CRUD key Conventions + :header-rows: 1 + + * - action + - django-rules + - drf-rules + * - Create + - add + - create + * - Retrieve + - view + - retrieve + * - Update + - change + - update/partial_update + * - Delete + - delete + - destroy + * - List + - view + - list + +As demonstrated, the keys in `drf-rules` can distinguish directly between +various types of update actions, such as `update` and `partial_update`. +Additionally, they can differentiate between `list` and `retrieve` actions. +This is because `drf-rules` is designed to align with Django Rest Framework +(DRF) conventions, enabling it to operate seamlessly with DRF actions. + +Another advantage of using this approach is that it facilitates an automatic +association between rules and Django Rest Framework (DRF) actions. As we will +see later, this allows for the seamless integration of `drf-rules` as +permissions in views. + + +Permissions in views +++++++++++++++++++++ + +This marks the first instance where we utilize ``drf-rules``. You can +configure the ``permission_classes`` attribute for a view or viewset by using +the ``ModelViewSet`` class-based views: + +.. code:: python + + from rest_framework.decorators import action + from rest_framework.viewsets import ModelViewSet + + from drf_rules.permissions import AutoRulesPermission + + + class BookViewSet(ModelViewSet): + queryset = Book.objects.all() + serializer_class = BookSerializer + permission_classes = [AutoRulesPermission] + + @action(detail=False) + def custom_nodetail(self, request): + return Response({'status': 'request was permitted'}) + +This defines permissions based on ``rules_permissions`` specified in the model. +To set permissions for custom actions, you can modify ``rules_permissions``. +For example, you can do this: + + +.. code:: python + + import rules + from rules.contrib.models import RulesModel + + class Book(RulesModel): + class Meta: + rules_permissions = { + "create": rules.is_staff, + "retrieve": rules.is_authenticated, + "custom_nodetail": rules.is_authenticated, + } + +With this configuration, the ``custom_nodetail`` action will be allowed only +to authenticated users. Note that the ``list``, ``update``, ``partial_update`` +and ``destroy`` actions are not explicitly defined. Therefore, the +``:default:`` rule will be applied. However, since the ``:default:`` rule is +not defined, these actions will not be allowed at all. The ``:default:`` rule +is applicable only to conventional actions, such as ``list``, ``retrieve``, +``create``, ``update``, ``partial_update``, and ``destroy``. To ensure that +the ``:default:`` rule applies to all conventional actions that are not +explicitly defined, you can define it accordingly: + +.. code:: python + + import rules + from rules.contrib.models import RulesModel + + class Book(RulesModel): + class Meta: + rules_permissions = { + "create": rules.is_staff, + "retrieve": rules.is_authenticated, + ":default:": rules.is_authenticated, + } + +In this case, if ``custom_nodetail`` rule is not explicitly defined, +``custom_nodetail`` action will not be allowed, even if the ``:default:`` is +specified. This is because ``custom_nodetail`` is not a conventional action. +However, the ``:default:`` rule will apply to the ``list``, ``update``, +``partial_update``, and ``destroy`` actions. + + +License +------- + +``drf-rules`` is distributed under the terms of the +`BSD-3-Clause `_ license. diff --git a/pyproject.toml b/pyproject.toml index f86df73..2c296ce 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "hatchling.build" name = "drf-rules" dynamic = ["version"] description = 'Rules Permissions with Django DRF' -readme = "README.md" +readme = "README.rst" requires-python = ">=3.8" license = { text = "BSD-3-Clause" } keywords = ["django", "drf", "rules", "permissions"] @@ -49,6 +49,17 @@ check = "mypy --install-types --non-interactive {args:src/drf_rules tests}" [tool.black] line-length = 79 +[tool.isort] +profile = 'black' +line_length = 79 +multi_line_output = 3 +include_trailing_comma = true +use_parentheses = true +ensure_newline_before_comments = true +extend_skip = ['migrations', '__pycache__'] +known_django = ['django', 'rest_framework', 'drf_spectacular'] +sections = ['FUTURE', 'STDLIB', 'THIRDPARTY', 'DJANGO', 'FIRSTPARTY', 'LOCALFOLDER'] + [tool.coverage.run] source_pkgs = ["drf_rules"] branch = true diff --git a/src/drf_rules/permissions.py b/src/drf_rules/permissions.py index 967a048..50e5705 100644 --- a/src/drf_rules/permissions.py +++ b/src/drf_rules/permissions.py @@ -3,13 +3,14 @@ # SPDX-License-Identifier: BSD-3-Clause import logging +from rules.contrib.models import RulesModel +from rules.permissions import perm_exists + from django.core.exceptions import ImproperlyConfigured from django.db.models import QuerySet from django.http import HttpRequest from rest_framework.generics import GenericAPIView from rest_framework.permissions import BasePermission -from rules.contrib.models import RulesModel -from rules.permissions import perm_exists logger = logging.getLogger("drf-rules") error_message = "Permission {} not found, please add it to rules_permissions!" @@ -70,13 +71,16 @@ class Meta: """ def _queryset(self, view: GenericAPIView) -> QuerySet: - assert ( - hasattr(view, "get_queryset") - or getattr(view, "queryset", None) is not None - ), ( - f"Cannot apply {self.__class__.__name__} on a view that does" - "not set `.queryset` or have a `.get_queryset()` method." - ) + if ( + not hasattr(view, "get_queryset") + and getattr(view, "queryset", None) is None + ): + message = ( + f"Cannot apply {self.__class__.__name__} on a view that does" + "not set `.queryset` and not have a `.get_queryset()` method." + ) + logger.warning(message) + raise ImproperlyConfigured(message) if hasattr(view, "get_queryset"): queryset = view.get_queryset() diff --git a/tests/testapp/models.py b/tests/testapp/models.py index 5ac8f0a..4d7d4df 100644 --- a/tests/testapp/models.py +++ b/tests/testapp/models.py @@ -1,9 +1,10 @@ from __future__ import absolute_import import rules -from django.db import models from rules.contrib.models import RulesModel +from django.db import models + from .rules import is_adult_cat @@ -19,6 +20,7 @@ class Cat(RulesModel): class Meta: rules_permissions = { + "post": rules.always_true, "create": rules.always_true, "retrieve": rules.always_true, "destroy": rules.is_staff, diff --git a/tests/testapp/tests.py b/tests/testapp/tests.py index d46e967..972f779 100644 --- a/tests/testapp/tests.py +++ b/tests/testapp/tests.py @@ -3,16 +3,25 @@ # SPDX-License-Identifier: BSD-3-Clause from typing import List +from testapp.models import Cat, Dog, Gender + from django.core.exceptions import ImproperlyConfigured -from django.urls import URLPattern +from django.urls import URLPattern, path from rest_framework.decorators import action +from rest_framework.request import Request from rest_framework.response import Response from rest_framework.reverse import reverse from rest_framework.routers import SimpleRouter from rest_framework.serializers import ModelSerializer +from rest_framework.status import ( + HTTP_200_OK, + HTTP_201_CREATED, + HTTP_400_BAD_REQUEST, + HTTP_403_FORBIDDEN, +) from rest_framework.test import APITestCase, URLPatternsTestCase +from rest_framework.views import APIView from rest_framework.viewsets import ModelViewSet -from testapp.models import Cat, Dog, Gender from drf_rules.permissions import AutoRulesPermission @@ -58,11 +67,44 @@ class DogViewSet(ModelViewSet): serializer_class = DogSerializer permission_classes = [AutoRulesPermission] + class CustomCatView(APIView): + queryset = Cat.objects.all() + permission_classes = [AutoRulesPermission] + + def get(self, request): + return Response() + + def post(self, request: Request): + serializer = CatSerializer(data=request.data) + if serializer.is_valid(): + serializer.save() + return Response(serializer.data, status=HTTP_201_CREATED) + return Response(serializer.errors, status=HTTP_400_BAD_REQUEST) + + class CustomDogView(APIView): + permission_classes = [AutoRulesPermission] + + def get(self, request): + return Response() + router = SimpleRouter() router.register("cats", CatViewSet) router.register("dogs", DogViewSet) cls.urlpatterns = router.get_urls() + cls.urlpatterns += [ + path( + "custom/cats/", + CustomCatView.as_view(), + name="custom-cats-list", + ), + path( + "custom/dogs/", + CustomDogView.as_view(), + name="custom-dogs-list", + ), + ] + return super().setUpClass() def test_predefined_cat_actions(self): @@ -75,7 +117,7 @@ def test_predefined_cat_actions(self): {"name": "michi", "age": 3, "gender": Gender.FEMALE}, format="json", ) - self.assertEqual(response.status_code, 201) + self.assertEqual(response.status_code, HTTP_201_CREATED) # update response = self.client.put( @@ -83,7 +125,7 @@ def test_predefined_cat_actions(self): {"name": "michi", "age": 2, "gender": Gender.MALE}, format="json", ) - self.assertEqual(response.status_code, 200) + self.assertEqual(response.status_code, HTTP_200_OK) # update response = self.client.put( @@ -91,7 +133,7 @@ def test_predefined_cat_actions(self): {"name": "michi", "age": 4, "gender": Gender.MALE}, format="json", ) - self.assertEqual(response.status_code, 403) + self.assertEqual(response.status_code, HTTP_403_FORBIDDEN) # partial_update response = self.client.patch( @@ -99,19 +141,19 @@ def test_predefined_cat_actions(self): {"age": 6}, format="json", ) - self.assertEqual(response.status_code, 200) + self.assertEqual(response.status_code, HTTP_200_OK) # list response = self.client.get(url, format="json") - self.assertEqual(response.status_code, 200) + self.assertEqual(response.status_code, HTTP_200_OK) # retrieve response = self.client.get(url_1, format="json") - self.assertEqual(response.status_code, 200) + self.assertEqual(response.status_code, HTTP_200_OK) # destroy response = self.client.delete(url_1, format="json") - self.assertEqual(response.status_code, 403) + self.assertEqual(response.status_code, HTTP_403_FORBIDDEN) def test_predefined_dog_actions(self): url = reverse("dog-list") @@ -123,7 +165,7 @@ def test_predefined_dog_actions(self): {"name": "puppy", "age": 3, "gender": Gender.FEMALE}, format="json", ) - self.assertEqual(response.status_code, 201) + self.assertEqual(response.status_code, HTTP_201_CREATED) # update with self.assertRaises(ImproperlyConfigured): @@ -147,11 +189,11 @@ def test_predefined_dog_actions(self): # retrieve response = self.client.get(url_1, format="json") - self.assertEqual(response.status_code, 200) + self.assertEqual(response.status_code, HTTP_200_OK) # destroy response = self.client.delete(url_1, format="json") - self.assertEqual(response.status_code, 403) + self.assertEqual(response.status_code, HTTP_403_FORBIDDEN) def test_custom_cat_actions(self): url = reverse("cat-custom-nodetail") @@ -159,11 +201,11 @@ def test_custom_cat_actions(self): # custom_nodetail response = self.client.get(url, format="json") - self.assertEqual(response.status_code, 200) + self.assertEqual(response.status_code, HTTP_200_OK) # custom_detail response = self.client.get(url_1, format="json") - self.assertEqual(response.status_code, 200) + self.assertEqual(response.status_code, HTTP_200_OK) def test_unknown_cat_action(self): url = reverse("cat-unknown-nodetail") @@ -176,3 +218,19 @@ def test_unknown_cat_action(self): # unkown_detail with self.assertRaises(ImproperlyConfigured): self.client.get(url_1, format="json") + + def test_custom_view(self): + url_cats = reverse("custom-cats-list") + url_dogs = reverse("custom-dogs-list") + + # get not in rules_permissions + with self.assertRaises(ImproperlyConfigured): + self.client.get(url_cats, format="json") + + # post + response = self.client.post(url_cats, {}, format="json") + self.assertEqual(response.status_code, HTTP_400_BAD_REQUEST) + + # get not in rules_permissions and queryset not in CustomDogView + with self.assertRaises(ImproperlyConfigured): + self.client.get(url_dogs, format="json")