├── .gitignore ├── .travis.yml ├── LICENSE ├── MANIFEST.in ├── README.md ├── docs ├── css │ └── extra.css └── index.md ├── dry_rest_permissions ├── __init__.py └── generics.py ├── mkdocs.yml ├── requirements.txt ├── runtests.py ├── setup.cfg ├── setup.py ├── tests ├── __init__.py ├── conftest.py ├── models.py └── tests.py └── tox.ini /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | *.db 3 | *~ 4 | .* 5 | 6 | html/ 7 | htmlcov/ 8 | coverage/ 9 | build/ 10 | dist/ 11 | *.egg-info/ 12 | MANIFEST 13 | 14 | bin/ 15 | include/ 16 | lib/ 17 | local/ 18 | 19 | !.gitignore 20 | !.travis.yml 21 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | 3 | sudo: false 4 | 5 | env: 6 | - TOX_ENV=py27-flake8 7 | - TOX_ENV=py27-docs 8 | - TOX_ENV=py27-django1.8-drf3.5 9 | - TOX_ENV=py27-django1.8-drf3.6 10 | - TOX_ENV=py27-django1.10-drf3.5 11 | - TOX_ENV=py27-django1.10-drf3.6 12 | - TOX_ENV=py34-django1.8-drf3.5 13 | - TOX_ENV=py34-django1.8-drf3.6 14 | - TOX_ENV=py34-django1.10-drf3.5 15 | - TOX_ENV=py34-django1.10-drf3.6 16 | - TOX_ENV=py35-django1.8-drf3.5 17 | - TOX_ENV=py35-django1.8-drf3.6 18 | - TOX_ENV=py35-django1.10-drf3.5 19 | - TOX_ENV=py35-django1.10-drf3.6 20 | - TOX_ENV=py36-django1.8-drf3.5 21 | - TOX_ENV=py36-django1.8-drf3.6 22 | - TOX_ENV=py36-django1.10-drf3.5 23 | - TOX_ENV=py36-django1.10-drf3.6 24 | 25 | matrix: 26 | fast_finish: true 27 | 28 | install: 29 | - pip install -r requirements.txt 30 | - pip install tox 31 | 32 | script: 33 | - python runtests.py 34 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2015, Helioscene 2 | 3 | Permission to use, copy, modify, and/or distribute this software for any 4 | purpose with or without fee is hereby granted, provided that the above 5 | copyright notice and this permission notice appear in all copies. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 8 | WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 9 | MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR 10 | ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 11 | WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN 12 | ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF 13 | OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. 14 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.rst LICENSE 2 | recursive-exclude * __pycache__ 3 | recursive-exclude * *.py[co] 4 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # dry-rest-permissions 2 | 3 | 4 | [](http://travis-ci.org/dbkaplan/dry-rest-permissions?branch=master) [](https://pypi.python.org/pypi/dry-rest-permissions) 5 | 6 | ## Overview 7 | 8 | Rules based permissions for the Django Rest Framework. 9 | 10 | This framework is a perfect fit for apps that have many tables and relationships between them. It provides a framework that allows you to define, for each action or groups of actions, what users have permission for based on existing data in your database. 11 | 12 | ## What does DRY Rest Permissions provide? 13 | 14 | 1. A framework for defining global and object level permissions per action. 15 | 1. Support for broadly defining permissions by grouping actions into safe and unsafe types. 16 | 2. Support for defining only global (table level) permissions or only object (row level) permissions. 17 | 3. Support for custom list and detail actions. 18 | 2. A serializer field that will return permissions for an object to your client app. This is DRY and works with your existing permission definitions. 19 | 3. A framework for limiting list requests based on permissions 20 | 1. Support for custom list actions 21 | 22 | ## Why is DRY Rest Permissions different than other DRF permission packages? 23 | 24 | Most other DRF permissions are based on django-guardian. Django-guardian is an explicit approach to permissions that requires data to be saved in tables that explicitly grants permissions for certain actions. For apps that have many ways for a user to be given permission to certain actions, this approach can be very hard to maintain. 25 | 26 | For example: you may have an app which lets you create and modify projects if you are an admin of an association that owns the project. This means that a user's permission will be granted or revoked based on many possibilities including ownership of the project transferring to a different association, the user's admin status in the association changing and the user entering or leaving the association. This would need a lot of triggers that would key off of these actions and explicitly change permissions. 27 | 28 | DRY Rest Permissions allows developers to easily describe what gives someone permission using the current data in an implicit way. 29 | 30 | ## Requirements 31 | 32 | - Python (2.7+) 33 | - Django (1.8, 1.10, 2.0) 34 | - Django REST Framework (3.5, 3.6, 3.7) 35 | 36 | ## Installation 37 | 38 | Install using ``pip``… 39 | 40 | $ pip install dry-rest-permissions 41 | 42 | ## Setup 43 | 44 | Add to INSTALLED_APPS 45 | ```python 46 | INSTALLED_APPS = ( 47 | ... 48 | 'dry_rest_permissions', 49 | ) 50 | ``` 51 | ## Global vs. Object Permissions 52 | DRY Rest Permissions allows you to define both global and object level permissions. 53 | 54 | Global permissions are always checked first and define the ability of a user to take an action on an entire model. For example you can define whether a user has the ability to update any projects from the database. 55 | 56 | Object permissions are checked if global permissions pass and define whether a user has the ability to perform a specific action on a single object. These are also known as row level permissions. 57 | Note: list and create actions are the only standard actions that are only global. There is no such object level permission call because they are whole table actions. 58 | 59 | ## Read/Write permissions vs. Specific Actions 60 | DRY Rest Permissions allows you to define permissions for both the standard actions (``list``, ``retrieve``, ``update``, ``destroy`` and ``create``) and custom actions defined using ``@detail_route`` and ``@list_route``. 61 | 62 | If you don't need to define permissions on a granular action level you can generally define read or write permissions for a model. "Read" permissions groups together list and retrieve, while "write" permissions groups together destroy, update and create. All custom actions that use ``GET`` methods are considered read actions and all other methods are considered write. 63 | 64 | Specific action permissions take precedence over general read or write permissions. For example you can lock down write permissions by always returning ``False`` and open up just update permissions for certain users. 65 | 66 | The ``partial_update`` action is also supported, but by default is grouped with the update permission. 67 | 68 | ## Add permissions to an API 69 | 70 | Permissions can be added to ModelViewSet based APIs. 71 | 72 | ### Add permission class to a ModelViewSet 73 | 74 | First you must add ``DRYPermissions`` to the viewset's ``permission_classes`` 75 | ```python 76 | from rest_framework import viewsets 77 | from dry_rest_permissions.generics import DRYPermissions 78 | 79 | 80 | class ProjectViewSet(viewsets.ModelViewSet): 81 | permission_classes = (DRYPermissions,) 82 | queryset = Project.objects.all() 83 | serializer_class = ProjectSerializer 84 | ``` 85 | You may also use ``DRYGlobalPermissions`` and ``DRYObjectPermissions``, which will only check global or object permissions. 86 | 87 | If you want to define DRYPermissions for only some method types you can override the get_permissions function on the view like this: 88 | ```python 89 | def get_permissions(self): 90 | if self.request.method == 'GET' or self.request.method == 'PUT': 91 | return [DRYPermissions(),] 92 | return [] 93 | ``` 94 | 95 | ### Define permission logic on the model 96 | Permissions for DRY Rest permissions are defined on the model so that they can be accessed both from the view for checking and from the serializer for display purposes with the ``DRYPermissionsField``. 97 | 98 | **Global permissions** are defined as either ``@staticmethod`` or ``@classmethod`` methods with the format ``has__permission``. 99 | 100 | **Object permissions** are defined as methods with the format ``has_object__permission``. 101 | 102 | The following example shows how you would allow all users to read and create projects, while locking down the ability for any user to perform any other write action. In the example, read global and object permissions return ``True``, which grants permission to those actions. Write, globally returns False, which locks down write actions. However, create is a specific action and therefore takes precedence over write and gives all users the ability to create projects. 103 | ```python 104 | from django.db import models 105 | from django.contrib.auth.models import User 106 | 107 | class Project(models.Model): 108 | owner = models.ForeignKey('User') 109 | 110 | @staticmethod 111 | def has_read_permission(request): 112 | return True 113 | 114 | def has_object_read_permission(self, request): 115 | return True 116 | 117 | @staticmethod 118 | def has_write_permission(request): 119 | return False 120 | 121 | @staticmethod 122 | def has_create_permission(request): 123 | return True 124 | ``` 125 | Now we will add to this example and allow project owners to update or destroy a project. 126 | ```python 127 | class Project(models.Model): 128 | owner = models.ForeignKey('User') 129 | ... 130 | 131 | @staticmethod 132 | def has_write_permission(request): 133 | """ 134 | We can remove the has_create_permission because this implicitly grants that permission. 135 | """ 136 | return True 137 | 138 | def has_object_write_permission(self, request): 139 | return request.user == self.owner 140 | ``` 141 | If we just wanted to grant update permission, but not destroy we could do this: 142 | ```python 143 | class Project(models.Model): 144 | owner = models.ForeignKey('User') 145 | ... 146 | 147 | @staticmethod 148 | def has_write_permission(request): 149 | """ 150 | We can remove the has_create_permission because this implicitly grants that permission. 151 | """ 152 | return True 153 | 154 | def has_object_write_permission(self, request): 155 | return False 156 | 157 | def has_object_update_permission(self, request): 158 | return request.user == self.owner 159 | ``` 160 | ### Custom action permissions 161 | If a custom action, ``publish``, were created using ``@detail_route`` then permissions could be defined like so: 162 | ```python 163 | class Project(models.Model): 164 | owner = models.ForeignKey('User') 165 | ... 166 | 167 | @staticmethod 168 | def has_publish_permission(request): 169 | return True 170 | 171 | def has_object_publish_permission(self, request): 172 | return request.user == self.owner 173 | ``` 174 | ### Helpful decorators 175 | Three decorators were defined for common permission checks 176 | ``@allow_staff_or_superuser`` - Allows any user that has staff or superuser status to have the permission. 177 | ``@authenticated_users`` - This permission will only be checked for authenticated users. Unauthenticated users will automatically be denied permission. 178 | ``@unauthenticated_users`` - This permission will only be checked for unauthenticated users. Authenticated users will automatically be denied permission. 179 | 180 | Example: 181 | ```python 182 | from dry_rest_permissions.generics import allow_staff_or_superuser, authenticated_users 183 | 184 | 185 | class Project(models.Model): 186 | owner = models.ForeignKey('User') 187 | ... 188 | 189 | @staticmethod 190 | @authenticated_users 191 | def has_publish_permission(request): 192 | return True 193 | 194 | @allow_staff_or_superuser 195 | def has_object_publish_permission(self, request): 196 | return request.user == self.owner 197 | ``` 198 | ## Returning Permissions to the Client App 199 | You often need to know all of the possible permissions that are available to the current user from within your client app so that you can show certain create, edit and destroy options. Sometimes you need to know the permissions on the client app so that you can display messages to them. ``DRYPermissionsField`` allows you to return these permissions in a serializer without having to redefine your permission logic. DRY! 200 | ```python 201 | from dry_rest_permissions.generics import DRYPermissionsField 202 | 203 | 204 | class ProjectSerializer(serializers.ModelSerializer): 205 | permissions = DRYPermissionsField() 206 | 207 | class Meta: 208 | model = Project 209 | fields = ('id', 'owner', 'permissions') 210 | ``` 211 | This response object will look like this: 212 | ```json 213 | { 214 | "id": 1, 215 | "owner": 100, 216 | "permissions": { 217 | "read": true, 218 | "write": false, 219 | "create": true, 220 | "update": true 221 | } 222 | } 223 | ``` 224 | #### Definition 225 | ``DRYPermissionsField(actions=None, additional_actions=None, global_only=False, object_only=False, **kwargs):`` 226 | 227 | ``actions`` - This can be passed a list in order to limit the actions that are looked up. 228 | 229 | ``additional_actions`` - If you add custom actions then you can have DRYPermissionsField look them up by adding an array of the actions as so ``permissions = DRYPermissionsField(additional_actions=['publish'])``. 230 | 231 | ``global_only`` - If set to ``True`` then it will only look up global permissions. 232 | 233 | ``object_only`` - If set to ``True`` then it will only look up object permissions. 234 | 235 | This field only returns what is defined on the model. By default it retrieves all default action types that are defined. 236 | 237 | A serializer with this field MUST have the request accessible via the serializer's context. By default DRF passes the request to all serializers that is creates. However, if you create a serializer yourself you will have to add the request manually like this: 238 | ```python 239 | serializer = TestSerializer(data=request.data, context={'request': request}) 240 | ``` 241 | 242 | ## Filtering lists by action type 243 | Many times it is not enough to say that a user does not have permission to view a list of items. Instead you want a user to only be able to view a partial list of items. In this case DRY Rest Permissions built on the filter concept using ``DRYPermissionFiltersBase`` to apply permissions to specific actions. 244 | 245 | If you want to apply the same permissions to all list requests (the standard one and custom list actions) you could do the following: 246 | ```python 247 | from django.db.models import Q 248 | from rest_framework import viewsets 249 | from dry_rest_permissions.generics import DRYPermissionFiltersBase 250 | 251 | 252 | class ProjectFilterBackend(DRYPermissionFiltersBase): 253 | 254 | def filter_list_queryset(self, request, queryset, view): 255 | """ 256 | Limits all list requests to only be seen by the owners or creators. 257 | """ 258 | return queryset.filter(Q(owner=request.user) | Q(creator=request.user)) 259 | 260 | 261 | class ProjectViewSet(viewsets.ModelViewSet): 262 | serializer_class = ProjectSerializer 263 | queryset = Project.objects.all() 264 | filter_backends = (ProjectFilterBackend,) 265 | ``` 266 | If you had a custom list action called ``owned`` that returned just the owned projects you could do this: 267 | ```python 268 | from django.db.models import Q 269 | from rest_framework import viewsets 270 | from dry_rest_permissions.generics import DRYPermissionFiltersBase 271 | 272 | 273 | class ProjectFilterBackend(DRYPermissionFiltersBase): 274 | action_routing = True 275 | 276 | def filter_list_queryset(self, request, queryset, view): 277 | """ 278 | Limits all list requests to only be seen by the owners or creators. 279 | """ 280 | return queryset.filter(Q(owner=request.user) | Q(creator=request.user)) 281 | 282 | def filter_owned_queryset(self, request, queryset, view): 283 | return queryset.filter(owner=request.user) 284 | 285 | 286 | class ProjectViewSet(viewsets.ModelViewSet): 287 | serializer_class = ProjectSerializer 288 | queryset = Project.objects.all() 289 | filter_backends = (ProjectFilterBackend,) 290 | ``` 291 | -------------------------------------------------------------------------------- /docs/css/extra.css: -------------------------------------------------------------------------------- 1 | body.homepage div.col-md-9 h1:first-of-type { 2 | text-align: center; 3 | font-size: 60px; 4 | font-weight: 300; 5 | margin-top: 0; 6 | } 7 | 8 | body.homepage div.col-md-9 p:first-of-type { 9 | text-align: center; 10 | } 11 | 12 | body.homepage .badges { 13 | text-align: right; 14 | } 15 | 16 | body.homepage .badges a { 17 | display: inline-block; 18 | } 19 | 20 | body.homepage .badges a img { 21 | padding: 0; 22 | margin: 0; 23 | } 24 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | 9 | 10 | --- 11 | 12 | #dry-rest-permissions 13 | 14 | ##Overview 15 | 16 | Rules based permissions for the Django Rest Framework. 17 | 18 | This framework is a perfect fit for apps that have many tables and relationships between them. It provides a framework that allows you to define, for each action or groups of actions, what users have permission for based on existing data in your database. 19 | 20 | ##What does DRY Rest Permissions provide? 21 | 22 | 1. A framework for defining for defining global and object level permissions per action. 23 | 1. Support for broadly defining permissions by grouping actions into safe and unsafe types. 24 | 2. Support for defining only global (table level) permissions or only object (row level) permissions. 25 | 3. Support for custom list and detail actions. 26 | 2. A serializer field that will return permissions for an object to your client app. This is DRY and works with your existing permission definitions. 27 | 3. A framework for limiting list requests based on permissions 28 | 1. Support for custom list actions 29 | 30 | ##Why is DRY Rest Permissions different than other DRF permission packages? 31 | 32 | Most other DRF permissions are based on django-guardian. Django-guardian is an explicit approach to permissions that requires data to be saved in tables that explicitly grants permissions for certain actions. For apps that have many ways for a user to be given permission to certain actions, this approach can be very hard to maintain. 33 | 34 | For example: you may have an app which lets you create and modify projects if you are an admin of an association that owns the project. This means that a user's permission will be granted or revoked based on many possibilities including ownership of the project transferring to a different association, the user's admin status in the association changing and the user entering or leaving the association. This would need a lot of triggers that would key off of these actions and explicitly change permissions. 35 | 36 | DRY Rest Permissions allows developers to easily describe what gives someone permission using the current data in an implicit way. 37 | 38 | ##Requirements 39 | 40 | - Python (2.7, 3.4+) 41 | - Django (1.7, 1.8, 1.9, 2.0) 42 | - Django REST Framework (3.0, 3.1, 3.7) 43 | 44 | ##Installation 45 | 46 | Install using ``pip``… 47 | 48 | $ pip install dry-rest-permissions 49 | 50 | ##Setup 51 | 52 | Add to INSTALLED_APPS 53 | 54 | INSTALLED_APPS = ( 55 | ... 56 | 'dry_rest_permissions',) 57 | 58 | ##Global vs. Object Permissions 59 | DRY Rest Permissions allows you to define both global and object level permissions. 60 | 61 | Global permissions are always checked first and define the ability of a user to take an action on an entire model. For example you can define whether a user has the ability to update any projects from the database. 62 | 63 | Object permissions are checked if global permissions pass and define whether a user has the ability to perform a specific action on a single object. These are also known as row level permissions. 64 | 65 | ##Read/Write permissions vs. Specific Actions 66 | DRY Rest Permissions allows you to define permissions for both the standard actions (``list``, ``retrieve``, ``update``, ``destroy`` and ``create``) and custom actions defined using ``@detail_route`` and ``@list_route``. 67 | 68 | If you don't need to define permissions on a granular action level you can generally define read or write permissions for a model. "Read" permissions groups together list and retrieve, while "write" permissions groups together destroy, update and create. All custom actions that use ``GET`` methods are considered read actions and all other methods are considered write. 69 | 70 | Specific action permissions take precedence over general read or write permissions. For example you can lock down write permissions by always returning ``False`` and open up just update permissions for certain users. 71 | 72 | The ``partial_update`` action is also supported, but by default is grouped with the update permission. 73 | 74 | ##Add permissions to an API 75 | 76 | Permissions can be added to ModelViewSet based APIs. 77 | 78 | ###Add permission class to a ModelViewSet 79 | 80 | First you must add ``DRYPermissions`` to the viewset's ``permission_classes`` 81 | 82 | from rest_framework import viewsets 83 | from dry_rest_permissions.generics import DRYPermissions 84 | 85 | class ProjectViewSet(viewsets.ModelViewSet): 86 | permission_classes = (DRYPermissions,) 87 | queryset = Project.objects.all() 88 | serializer_class = ProjectSerializer 89 | 90 | You may also use ``DRYGlobalPermissions`` and ``DRYObjectPermissions``, which will only check global or object permissions. 91 | 92 | ###Define permission logic on the model 93 | Permissions for DRY Rest permissions are defined on the model so that they can be accessed both from the view for checking and from the serializer for display purposes with the ``DRYPermissionsField``. 94 | 95 | **Global permissions** are defined as either ``@staticmethod`` or ``@classmethod`` methods with the format ``has__permission``. 96 | 97 | **Object permissions** are defined as methods with the format ``has_object__permission``. 98 | 99 | The following example shows how you would allow all users to read and create projects, while locking down the ability for any user to perform any other write action. In the example, read global and object permissions return ``True``, which grants permission to those actions. Write, globally returns False, which locks down write actions. However, create is a specific action and therefore takes precedence over write and gives all users the ability to create projects. 100 | 101 | from django.db import models 102 | from django.contrib.auth.models import User 103 | 104 | class Project(models.Model): 105 | owner = models.ForeignKey('User') 106 | 107 | @staticmethod 108 | def has_read_permission(request): 109 | return True 110 | 111 | def has_object_read_permission(self, request): 112 | return True 113 | 114 | @staticmethod 115 | def has_write_permission(request): 116 | return False 117 | 118 | @staticmethod 119 | def has_create_permission(request): 120 | return True 121 | 122 | Now we will add to this example and allow project owners to update or destroy a project. 123 | 124 | class Project(models.Model): 125 | owner = models.ForeignKey('User') 126 | ... 127 | 128 | @staticmethod 129 | def has_write_permission(request): 130 | """ 131 | We can remove the has_create_permission because this implicitly grants that permission. 132 | """ 133 | return True 134 | 135 | def has_object_write_permission(self, request): 136 | if request.user == self.owner: 137 | return True 138 | return False 139 | 140 | If we just wanted to grant update permission, but not destroy we could do this: 141 | 142 | class Project(models.Model): 143 | owner = models.ForeignKey('User') 144 | ... 145 | 146 | @staticmethod 147 | def has_write_permission(request): 148 | """ 149 | We can remove the has_create_permission because this implicitly grants that permission. 150 | """ 151 | return True 152 | 153 | def has_object_write_permission(self, request): 154 | return False 155 | 156 | def has_object_update_permission(self, request): 157 | if request.user == self.owner: 158 | return True 159 | return False 160 | 161 | ###Custom action permissions 162 | If a custom action, ``publish``, were created using ``@detail_route`` then permissions could be defined like so: 163 | 164 | class Project(models.Model): 165 | owner = models.ForeignKey('User') 166 | ... 167 | 168 | @staticmethod 169 | def has_publish_permission(request): 170 | return True 171 | 172 | def has_object_publish_permission(self, request): 173 | if request.user == self.owner: 174 | return True 175 | return False 176 | 177 | ###Helpful decorators 178 | Three decorators were defined for common permission checks 179 | ``@allow_staff_or_superuser`` - Allows any user that has staff or superuser status to have the permission. 180 | ``@authenticated_users`` - This permission will only be checked for authenticated users. Unauthenticated users will automatically be denied permission. 181 | ``@unauthenticated_users`` - This permission will only be checked for unauthenticated users. Authenticated users will automatically be denied permission. 182 | 183 | Example: 184 | 185 | from dry_rest_permissions.generics import allow_staff_or_superuser, authenticated_users 186 | class Project(models.Model): 187 | owner = models.ForeignKey('User') 188 | ... 189 | 190 | @staticmethod 191 | @authenticated_users 192 | def has_publish_permission(request): 193 | return True 194 | 195 | @allow_staff_or_sueruser 196 | def has_object_publish_permission(self, request): 197 | if request.user == self.owner: 198 | return True 199 | return False 200 | 201 | ##Returning Permissions to the Client App 202 | You often need to know all of the possible permissions that are available to the current user from within your client app so that you can show certain create, edit and destroy options. Sometimes you need to know the permissions on the client app so that you can display messages to them. ``DRYPermissionsField`` allows you to return these permissions in a serializer without having to redefine your permission logic. DRY! 203 | 204 | from dry_rest_permissions.generics import DRYPermissionsField 205 | 206 | class ProjectSerializer(serializers.ModelSerializer): 207 | permissions = DRYPermissionsField() 208 | 209 | class Meta: 210 | model = Project 211 | fields = ('id', 'owner', 'permissions') 212 | 213 | This response object will look like this: 214 | 215 | { 216 | "id": 1, 217 | "owner": 100, 218 | "permissions": { 219 | "read": true, 220 | "write": false, 221 | "create": true, 222 | "update": true 223 | } 224 | } 225 | 226 | ####Definition 227 | ``DRYPermissionsField(actions=None, additional_actions=None, global_only=False, object_only=False, **kwargs):`` 228 | 229 | ``actions`` - This can be passed a list in order to limit the actions that are looked up. 230 | 231 | ``additional_actions`` - If you add custom actions then you can have DRYPermissionsField look them up by adding an array of the actions as so ``permissions = DRYPermissionsField(additional_actions=['publish'])``. 232 | 233 | ``global_only`` - If set to ``True`` then it will only look up global permissions. 234 | 235 | ``object_only`` - If set to ``True`` then it will only look up object permissions. 236 | 237 | This field only returns what is defined on the model. By default it retrieves all default action types that are defined. 238 | 239 | ##Filtering lists by action type 240 | Many times it is not enough to say that a user does not have permission to view a list of items. Instead you want a user to only be able to view a partial list of items. In this case DRY Rest Permissions built on the filter concept using ``DRYPermissionFiltersBase`` to apply permissions to specific actions. 241 | 242 | If you want to apply the same permissions to all list requests (the standard one and custom list actions) you could do the following: 243 | 244 | from dry_rest_permissions.generics import DRYPermissionFiltersBase 245 | 246 | class ProjectFilterBackend(DRYPermissionFiltersBase): 247 | def filter_list_queryset(self, request, queryset, view): 248 | """ 249 | Limits all list requests to only be seen by the owners or creators. 250 | """ 251 | return queryset.filter(Q(owner=request.user) | Q(creator=request.user)) 252 | 253 | class ProjectViewSet(viewsets.ModelViewSet): 254 | serializer_class = ProjectSerializer 255 | queryset = Project.objects.all() 256 | filter_backends = (ProjectFilterBackend,) 257 | 258 | If you had a custom list action called ``owned`` that returned just the owned projects you could do this: 259 | 260 | from dry_rest_permissions.generics import DRYPermissionFiltersBase 261 | 262 | class ProjectFilterBackend(DRYPermissionFiltersBase): 263 | action_routing = True 264 | 265 | def filter_list_queryset(self, request, queryset, view): 266 | """ 267 | Limits all list requests to only be seen by the owners or creators. 268 | """ 269 | return queryset.filter(Q(owner=request.user) | Q(creator=request.user)) 270 | 271 | def filter_owned_queryset(self, request, queryset, view): 272 | return queryset.filter(owner=request.user) 273 | 274 | class ProjectViewSet(viewsets.ModelViewSet): 275 | serializer_class = ProjectSerializer 276 | queryset = Project.objects.all() 277 | filter_backends = (ProjectFilterBackend,) 278 | 279 | -------------------------------------------------------------------------------- /dry_rest_permissions/__init__.py: -------------------------------------------------------------------------------- 1 | __version__ = '0.1.10' 2 | -------------------------------------------------------------------------------- /dry_rest_permissions/generics.py: -------------------------------------------------------------------------------- 1 | """ 2 | Provides a set of pluggable permission classes, filter classes and 3 | field classes that can be used with django-rest-framework. 4 | The goal of these classes is to allow easy development of permissions 5 | for CRUD actions, list actions and custom actions. It also allows 6 | permission checks to be returned by the api per object so that 7 | they can be consumed by a front end application. 8 | """ 9 | from functools import wraps 10 | 11 | from rest_framework import filters 12 | from rest_framework import permissions 13 | from rest_framework import fields 14 | 15 | 16 | class DRYPermissionFiltersBase(filters.BaseFilterBackend): 17 | """ 18 | This class is a base that should be inherited, not used directly on 19 | a view. This base is intended to be used to limit the records a 20 | requester can retrieve in a list request for permission purposes. 21 | This class abstracts away the logic for determining whether the request 22 | is a list type request. 23 | 24 | override filter_list_queryset(self, request, queryset, view) to limit 25 | list type requests. 26 | 27 | If action_routing is set to True then you can add additional methods 28 | to filter custom actions. The format for those methods is 29 | filter_{action}_queryset 30 | e.g. filter_owned_queryset for a custom 'owned' list type requested 31 | created on a view with the @list_route decorator. 32 | """ 33 | action_routing = False 34 | 35 | def filter_queryset(self, request, queryset, view): 36 | """ 37 | This method overrides the standard filter_queryset method. 38 | This method will check to see if the view calling this is from 39 | a list type action. This function will also route the filter 40 | by action type if action_routing is set to True. 41 | """ 42 | # Check if this is a list type request 43 | if view.lookup_field not in view.kwargs: 44 | if not self.action_routing: 45 | return self.filter_list_queryset(request, queryset, view) 46 | else: 47 | method_name = "filter_{action}_queryset".format(action=view.action) 48 | return getattr(self, method_name)(request, queryset, view) 49 | return queryset 50 | 51 | def filter_list_queryset(self, request, queryset, view): 52 | """ 53 | Override this function to add filters. 54 | This should return a queryset so start with queryset.filter({your filters}) 55 | """ 56 | assert False, "Method filter_list_queryset must be overridden on '%s'" % view.__class__.__name__ 57 | 58 | 59 | class DRYPermissions(permissions.BasePermission): 60 | """ 61 | This class can be used directly by a DRF view or can be used as a 62 | base class for a custom permissions class. This class helps to organize 63 | permission methods that are defined on the model class that is defined 64 | on the serializer for this view. 65 | 66 | DRYPermissions will call action based methods on the model in the following order: 67 | 1) Global permissions (format has_{action}_permission): 68 | 1a) specific action permissions (e.g. has_retrieve_permission) 69 | 1b) general action permissions (e.g. has_read_permission) 70 | 2) Object permissions for a specific object (format has_object_{action}_permission): 71 | 2a) specific action permissions (e.g. has_object_retrieve_permission) 72 | 2b) general action permissions (e.g. has_object_read_permission) 73 | 74 | If either of the specific permissions do not exist, the DRYPermissions will 75 | simply check the general permission. 76 | If any step in this process returns False then the checks stop there and 77 | throw a permission denied. If there is a "specific action" step then the 78 | "generic step" is skipped. In order to have permission there must be True returned 79 | from both the Global and Object permissions categories, unless the global_permissions 80 | or object_permissions attributes are set to False. 81 | 82 | Specific action permissions take their name from the action name, 83 | which is either DRF defined (list, retrieve, update, destroy, create) 84 | or developer defined for custom actions created using @list_route or @detail_route. 85 | 86 | Options that may be overridden when using as a base class: 87 | global_permissions: If set to False then global permissions are not checked. 88 | object_permissions: If set to False then object permissions are not checked. 89 | partial_update_is_update: If set to False then specific permissions for 90 | partial_update can be set, otherwise they will just use update permissions. 91 | 92 | """ 93 | global_permissions = True 94 | object_permissions = True 95 | partial_update_is_update = True 96 | 97 | def has_permission(self, request, view): 98 | """ 99 | Overrides the standard function and figures out methods to call for global permissions. 100 | """ 101 | if not self.global_permissions: 102 | return True 103 | 104 | serializer_class = view.get_serializer_class() 105 | 106 | assert serializer_class.Meta.model is not None, ( 107 | "global_permissions set to true without a model " 108 | "set on the serializer for '%s'" % view.__class__.__name__ 109 | ) 110 | 111 | model_class = serializer_class.Meta.model 112 | 113 | action_method_name = None 114 | if hasattr(view, 'action'): 115 | action = self._get_action(view.action) 116 | action_method_name = "has_{action}_permission".format(action=action) 117 | # If the specific action permission exists then use it, otherwise use general. 118 | if hasattr(model_class, action_method_name): 119 | return getattr(model_class, action_method_name)(request) 120 | 121 | if request.method in permissions.SAFE_METHODS: 122 | assert hasattr(model_class, 'has_read_permission'), \ 123 | self._get_error_message(model_class, 'has_read_permission', action_method_name) 124 | return model_class.has_read_permission(request) 125 | else: 126 | assert hasattr(model_class, 'has_write_permission'), \ 127 | self._get_error_message(model_class, 'has_write_permission', action_method_name) 128 | return model_class.has_write_permission(request) 129 | 130 | def has_object_permission(self, request, view, obj): 131 | """ 132 | Overrides the standard function and figures out methods to call for object permissions. 133 | """ 134 | if not self.object_permissions: 135 | return True 136 | 137 | serializer_class = view.get_serializer_class() 138 | model_class = serializer_class.Meta.model 139 | action_method_name = None 140 | if hasattr(view, 'action'): 141 | action = self._get_action(view.action) 142 | action_method_name = "has_object_{action}_permission".format(action=action) 143 | # If the specific action permission exists then use it, otherwise use general. 144 | if hasattr(obj, action_method_name): 145 | return getattr(obj, action_method_name)(request) 146 | 147 | if request.method in permissions.SAFE_METHODS: 148 | assert hasattr(obj, 'has_object_read_permission'), \ 149 | self._get_error_message(model_class, 'has_object_read_permission', action_method_name) 150 | return obj.has_object_read_permission(request) 151 | else: 152 | assert hasattr(obj, 'has_object_write_permission'), \ 153 | self._get_error_message(model_class, 'has_object_write_permission', action_method_name) 154 | return obj.has_object_write_permission(request) 155 | 156 | def _get_action(self, action): 157 | """ 158 | Utility function that consolidates actions if necessary. 159 | """ 160 | return_action = action 161 | if self.partial_update_is_update and action == 'partial_update': 162 | return_action = 'update' 163 | return return_action 164 | 165 | def _get_error_message(self, model_class, method_name, action_method_name): 166 | """ 167 | Get assertion error message depending if there are actions permissions methods defined. 168 | """ 169 | if action_method_name: 170 | return "'{}' does not have '{}' or '{}' defined.".format(model_class, method_name, action_method_name) 171 | else: 172 | return "'{}' does not have '{}' defined.".format(model_class, method_name) 173 | 174 | 175 | class DRYGlobalPermissions(DRYPermissions): 176 | """ 177 | This is a shortcut class that can be used to only check global permissions on a model. 178 | """ 179 | object_permissions = False 180 | 181 | 182 | class DRYObjectPermissions(DRYPermissions): 183 | """ 184 | This is a shortcut class that can be used to only check object permissions on a model. 185 | """ 186 | global_permissions = False 187 | 188 | 189 | class DRYPermissionsField(fields.Field): 190 | """ 191 | This is a field that can be used on a DRF model serializer class. Often a user interface 192 | needs to know what permissions a user has so that it can change the interface accordingly. 193 | This field will call the same developer defined model methods (hence the DRY) that DRYPermissions 194 | uses and create a dictionary of all permissions defined and whether the requester currently 195 | has access or not. 196 | 197 | This will only return permissions that are defined by methods. For example it will not return 198 | retrieve: True if the read permission is defined. 199 | 200 | This will combine object and global permissions to only return True if both are True. If you 201 | need to know whether a requester specifically has object or global permissions for informational 202 | purposes then use the global_only or object_only parameters. 203 | 204 | other parameters: 205 | actions: Add a list of strings here to specifically identify the actions this looks up. If left as None 206 | then it will return the default CRUD actions along with list and read and write. 207 | additional_actions: Add a list of strings here to add on to the default actions, without having to repeat them. 208 | """ 209 | default_actions = ['create', 'retrieve', 'update', 'destroy', 'write', 'read'] 210 | 211 | def __init__(self, actions=None, additional_actions=None, global_only=False, object_only=False, **kwargs): 212 | """See class description for parameters and usage""" 213 | assert not (global_only and object_only), ( 214 | "Both global_only and object_only cannot be set to true " 215 | "on a DRYPermissionsField" 216 | ) 217 | self.action_method_map = {} 218 | 219 | self.global_only = global_only 220 | self.object_only = object_only 221 | self.actions = self.default_actions if (actions is None) else actions 222 | if additional_actions is not None: 223 | self.actions = self.actions + additional_actions 224 | 225 | kwargs['source'] = '*' 226 | kwargs['read_only'] = True 227 | super(DRYPermissionsField, self).__init__(**kwargs) 228 | 229 | def bind(self, field_name, parent): 230 | """ 231 | Check the model attached to the serializer to see what methods are defined and save them. 232 | """ 233 | assert parent.Meta.model is not None, \ 234 | "DRYPermissions is used on '{}' without a model".format(parent.__class__.__name__) 235 | 236 | for action in self.actions: 237 | 238 | if not self.object_only: 239 | global_method_name = "has_{action}_permission".format(action=action) 240 | if hasattr(parent.Meta.model, global_method_name): 241 | self.action_method_map[action] = {'global': global_method_name} 242 | 243 | if not self.global_only: 244 | object_method_name = "has_object_{action}_permission".format(action=action) 245 | if hasattr(parent.Meta.model, object_method_name): 246 | if self.action_method_map.get(action, None) is None: 247 | self.action_method_map[action] = {} 248 | self.action_method_map[action]['object'] = object_method_name 249 | 250 | super(DRYPermissionsField, self).bind(field_name, parent) 251 | 252 | def to_representation(self, value): 253 | """ 254 | Calls the developer defined permission methods 255 | (both global and object) and formats the results into a dictionary. 256 | """ 257 | results = {} 258 | for action, method_names in self.action_method_map.items(): 259 | # If using global permissions and the global method exists for this action. 260 | if not self.object_only and method_names.get('global', None) is not None: 261 | results[action] = getattr(self.parent.Meta.model, method_names['global'])(self.context['request']) 262 | # If using object permissions, the global permission did not already evaluate to False and the object 263 | # method exists for this action. 264 | if not self.global_only and results.get(action, True) and method_names.get('object', None) is not None: 265 | results[action] = getattr(value, method_names['object'])(self.context['request']) 266 | return results 267 | 268 | 269 | def allow_staff_or_superuser(func): 270 | """ 271 | This decorator is used to abstract common is_staff and is_superuser functionality 272 | out of permission checks. It determines which parameter is the request based on name. 273 | """ 274 | is_object_permission = "has_object" in func.__name__ 275 | 276 | @wraps(func) 277 | def func_wrapper(*args, **kwargs): 278 | request = args[0] 279 | # use second parameter if object permission 280 | if is_object_permission: 281 | request = args[1] 282 | 283 | if request.user.is_staff or request.user.is_superuser: 284 | return True 285 | 286 | return func(*args, **kwargs) 287 | 288 | return func_wrapper 289 | 290 | 291 | def authenticated_users(func): 292 | """ 293 | This decorator is used to abstract common authentication checking functionality 294 | out of permission checks. It determines which parameter is the request based on name. 295 | """ 296 | is_object_permission = "has_object" in func.__name__ 297 | 298 | @wraps(func) 299 | def func_wrapper(*args, **kwargs): 300 | request = args[0] 301 | # use second parameter if object permission 302 | if is_object_permission: 303 | request = args[1] 304 | 305 | if not(request.user and request.user.is_authenticated): 306 | return False 307 | 308 | return func(*args, **kwargs) 309 | 310 | return func_wrapper 311 | 312 | 313 | def unauthenticated_users(func): 314 | """ 315 | This decorator is used to abstract common unauthentication checking functionality 316 | out of permission checks. It determines which parameter is the request based on name. 317 | """ 318 | is_object_permission = "has_object" in func.__name__ 319 | 320 | @wraps(func) 321 | def func_wrapper(*args, **kwargs): 322 | request = args[0] 323 | # use second parameter if object permission 324 | if is_object_permission: 325 | request = args[1] 326 | 327 | if request.user and request.user.is_authenticated: 328 | return False 329 | 330 | return func(*args, **kwargs) 331 | 332 | return func_wrapper 333 | -------------------------------------------------------------------------------- /mkdocs.yml: -------------------------------------------------------------------------------- 1 | site_name: dry-rest-permissions 2 | site_description: Rules based permissions for the Django Rest Framework 3 | repo_url: https://github.com/helioscene/dry-rest-permissions 4 | site_dir: html 5 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | # Minimum Django and REST framework version 2 | Django>=1.6,<2.0; python_version<'3.0' 3 | Django>=2.0; python_version>='3.0' 4 | djangorestframework>=2.4.3 5 | 6 | # Test requirements 7 | pytest-django==2.8.0 8 | pytest==2.5.2 9 | pytest-cov==1.6 10 | flake8==2.2.2 11 | 12 | # wheel for PyPI installs 13 | wheel==0.24.0 14 | 15 | # MkDocs for documentation previews/deploys 16 | mkdocs==0.11.1 17 | -------------------------------------------------------------------------------- /runtests.py: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env python 2 | from __future__ import print_function 3 | 4 | import pytest 5 | import sys 6 | import os 7 | import subprocess 8 | 9 | 10 | PYTEST_ARGS = { 11 | 'default': ['tests/tests.py'], 12 | 'fast': ['tests/tests.py', '-q'], 13 | } 14 | 15 | FLAKE8_ARGS = ['dry_rest_permissions', 'tests', '--ignore=E501'] 16 | 17 | 18 | sys.path.append(os.path.dirname(__file__)) 19 | 20 | 21 | def exit_on_failure(ret, message=None): 22 | if ret: 23 | sys.exit(ret) 24 | 25 | 26 | def flake8_main(args): 27 | print('Running flake8 code linting') 28 | ret = subprocess.call(['flake8'] + args) 29 | print('flake8 failed' if ret else 'flake8 passed') 30 | return ret 31 | 32 | 33 | def split_class_and_function(string): 34 | class_string, function_string = string.split('.', 1) 35 | return "%s and %s" % (class_string, function_string) 36 | 37 | 38 | def is_function(string): 39 | # `True` if it looks like a test function is included in the string. 40 | return string.startswith('test_') or '.test_' in string 41 | 42 | 43 | def is_class(string): 44 | # `True` if first character is uppercase - assume it's a class name. 45 | return string[0] == string[0].upper() 46 | 47 | 48 | if __name__ == "__main__": 49 | try: 50 | sys.argv.remove('--nolint') 51 | except ValueError: 52 | run_flake8 = True 53 | else: 54 | run_flake8 = False 55 | 56 | try: 57 | sys.argv.remove('--lintonly') 58 | except ValueError: 59 | run_tests = True 60 | else: 61 | run_tests = False 62 | 63 | try: 64 | sys.argv.remove('--fast') 65 | except ValueError: 66 | style = 'default' 67 | else: 68 | style = 'fast' 69 | run_flake8 = False 70 | 71 | if len(sys.argv) > 1: 72 | pytest_args = sys.argv[1:] 73 | first_arg = pytest_args[0] 74 | if first_arg.startswith('-'): 75 | # `runtests.py [flags]` 76 | pytest_args = ['tests'] + pytest_args 77 | elif is_class(first_arg) and is_function(first_arg): 78 | # `runtests.py TestCase.test_function [flags]` 79 | expression = split_class_and_function(first_arg) 80 | pytest_args = ['tests', '-k', expression] + pytest_args[1:] 81 | elif is_class(first_arg) or is_function(first_arg): 82 | # `runtests.py TestCase [flags]` 83 | # `runtests.py test_function [flags]` 84 | pytest_args = ['tests', '-k', pytest_args[0]] + pytest_args[1:] 85 | else: 86 | pytest_args = PYTEST_ARGS[style] 87 | 88 | if run_tests: 89 | exit_on_failure(pytest.main(pytest_args)) 90 | if run_flake8: 91 | exit_on_failure(flake8_main(FLAKE8_ARGS)) 92 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [wheel] 2 | universal = 1 3 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | import re 4 | import os 5 | import sys 6 | from setuptools import setup 7 | 8 | 9 | name = 'dry-rest-permissions' 10 | package = 'dry_rest_permissions' 11 | description = 'Rules based permissions for the Django Rest Framework' 12 | url = 'https://github.com/Helioscene/dry-rest-permissions' 13 | author = 'Heliosene, David B. Kaplan' 14 | author_email = 'info@helioscene.com' 15 | license = 'BSD' 16 | 17 | 18 | def get_version(package): 19 | """ 20 | Return package version as listed in `__version__` in `init.py`. 21 | """ 22 | init_py = open(os.path.join(package, '__init__.py')).read() 23 | return re.search("^__version__ = ['\"]([^'\"]+)['\"]", 24 | init_py, re.MULTILINE).group(1) 25 | 26 | 27 | def get_packages(package): 28 | """ 29 | Return root package and all sub-packages. 30 | """ 31 | return [dirpath 32 | for dirpath, dirnames, filenames in os.walk(package) 33 | if os.path.exists(os.path.join(dirpath, '__init__.py'))] 34 | 35 | 36 | def get_package_data(package): 37 | """ 38 | Return all files under the root package, that are not in a 39 | package themselves. 40 | """ 41 | walk = [(dirpath.replace(package + os.sep, '', 1), filenames) 42 | for dirpath, dirnames, filenames in os.walk(package) 43 | if not os.path.exists(os.path.join(dirpath, '__init__.py'))] 44 | 45 | filepaths = [] 46 | for base, filenames in walk: 47 | filepaths.extend([os.path.join(base, filename) 48 | for filename in filenames]) 49 | return {package: filepaths} 50 | 51 | 52 | version = get_version(package) 53 | 54 | 55 | if sys.argv[-1] == 'publish': 56 | if os.system("pip freeze | grep wheel"): 57 | print("wheel not installed.\nUse `pip install wheel`.\nExiting.") 58 | sys.exit() 59 | os.system("python setup.py sdist upload") 60 | os.system("python setup.py bdist_wheel upload") 61 | print("You probably want to also tag the version now:") 62 | print(" git tag -a {0} -m 'version {0}'".format(version)) 63 | print(" git push --tags") 64 | sys.exit() 65 | 66 | 67 | setup( 68 | name=name, 69 | version=version, 70 | url=url, 71 | license=license, 72 | description=description, 73 | author=author, 74 | author_email=author_email, 75 | packages=get_packages(package), 76 | package_data=get_package_data(package), 77 | install_requires=[], 78 | classifiers=[ 79 | 'Development Status :: 5 - Production/Stable', 80 | 'Environment :: Web Environment', 81 | 'Framework :: Django', 82 | 'Intended Audience :: Developers', 83 | 'License :: OSI Approved :: BSD License', 84 | 'Operating System :: OS Independent', 85 | 'Natural Language :: English', 86 | 'Programming Language :: Python :: 2.7', 87 | 'Programming Language :: Python :: 3.4', 88 | 'Programming Language :: Python :: 3.5', 89 | 'Programming Language :: Python :: 3.6', 90 | 'Topic :: Internet :: WWW/HTTP', 91 | ] 92 | ) 93 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dbkaplan/dry-rest-permissions/b2d4d3c76041f6c405e2537bea9639657b75b90e/tests/__init__.py -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | def pytest_configure(): 2 | from django.conf import settings 3 | 4 | settings.configure( 5 | DEBUG_PROPAGATE_EXCEPTIONS=True, 6 | DATABASES={'default': {'ENGINE': 'django.db.backends.sqlite3', 7 | 'NAME': ':memory:'}}, 8 | SITE_ID=1, 9 | SECRET_KEY='not very secret in tests', 10 | USE_I18N=True, 11 | USE_L10N=True, 12 | STATIC_URL='/static/', 13 | ROOT_URLCONF='tests.urls', 14 | TEMPLATE_LOADERS=( 15 | 'django.template.loaders.filesystem.Loader', 16 | 'django.template.loaders.app_directories.Loader', 17 | ), 18 | MIDDLEWARE_CLASSES=( 19 | 'django.middleware.common.CommonMiddleware', 20 | 'django.contrib.sessions.middleware.SessionMiddleware', 21 | 'django.middleware.csrf.CsrfViewMiddleware', 22 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 23 | 'django.contrib.messages.middleware.MessageMiddleware', 24 | ), 25 | INSTALLED_APPS=( 26 | 'django.contrib.auth', 27 | 'django.contrib.contenttypes', 28 | 'django.contrib.sessions', 29 | 'django.contrib.sites', 30 | 'django.contrib.messages', 31 | 'django.contrib.staticfiles', 32 | 33 | 'rest_framework', 34 | 'rest_framework.authtoken', 35 | 'tests', 36 | ), 37 | PASSWORD_HASHERS=( 38 | 'django.contrib.auth.hashers.SHA1PasswordHasher', 39 | 'django.contrib.auth.hashers.PBKDF2PasswordHasher', 40 | 'django.contrib.auth.hashers.PBKDF2SHA1PasswordHasher', 41 | 'django.contrib.auth.hashers.BCryptPasswordHasher', 42 | 'django.contrib.auth.hashers.MD5PasswordHasher', 43 | 'django.contrib.auth.hashers.CryptPasswordHasher', 44 | ), 45 | ) 46 | 47 | try: 48 | import oauth_provider # NOQA 49 | import oauth2 # NOQA 50 | except ImportError: 51 | pass 52 | else: 53 | settings.INSTALLED_APPS += ( 54 | 'oauth_provider', 55 | ) 56 | 57 | try: 58 | import provider # NOQA 59 | except ImportError: 60 | pass 61 | else: 62 | settings.INSTALLED_APPS += ( 63 | 'provider', 64 | 'provider.oauth2', 65 | ) 66 | 67 | # guardian is optional 68 | try: 69 | import guardian # NOQA 70 | except ImportError: 71 | pass 72 | else: 73 | settings.ANONYMOUS_USER_ID = -1 74 | settings.AUTHENTICATION_BACKENDS = ( 75 | 'django.contrib.auth.backends.ModelBackend', 76 | 'guardian.backends.ObjectPermissionBackend', 77 | ) 78 | settings.INSTALLED_APPS += ( 79 | 'guardian', 80 | ) 81 | 82 | try: 83 | import django 84 | django.setup() 85 | except AttributeError: 86 | pass 87 | -------------------------------------------------------------------------------- /tests/models.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dbkaplan/dry-rest-permissions/b2d4d3c76041f6c405e2537bea9639657b75b90e/tests/models.py -------------------------------------------------------------------------------- /tests/tests.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | from django.test import TestCase 3 | from django.test.client import RequestFactory 4 | from rest_framework.request import Request 5 | from rest_framework import serializers 6 | from rest_framework import viewsets 7 | from dry_rest_permissions.generics import DRYPermissions, DRYObjectPermissions, DRYGlobalPermissions, DRYPermissionsField, DRYPermissionFiltersBase 8 | 9 | 10 | class DummyModel(models.Model): 11 | test_field = models.TextField() 12 | 13 | 14 | class BaseGlobalMixin(object): 15 | base_global_allowed = True 16 | 17 | @classmethod 18 | def has_read_permission(cls, request): 19 | return cls.base_global_allowed 20 | 21 | @classmethod 22 | def has_write_permission(cls, request): 23 | return cls.base_global_allowed 24 | 25 | 26 | class BaseObjectMixin(object): 27 | base_object_allowed = True 28 | 29 | def has_object_read_permission(self, request): 30 | return self.base_object_allowed 31 | 32 | def has_object_write_permission(self, request): 33 | return self.base_object_allowed 34 | 35 | 36 | class SpecificGlobalMixin(object): 37 | specific_global_allowed = True 38 | 39 | @classmethod 40 | def has_list_permission(cls, request): 41 | return cls.specific_global_allowed 42 | 43 | @classmethod 44 | def has_create_permission(cls, request): 45 | return cls.specific_global_allowed 46 | 47 | @classmethod 48 | def has_destroy_permission(cls, request): 49 | return cls.specific_global_allowed 50 | 51 | @classmethod 52 | def has_retrieve_permission(cls, request): 53 | return cls.specific_global_allowed 54 | 55 | @classmethod 56 | def has_update_permission(cls, request): 57 | return cls.specific_global_allowed 58 | 59 | @classmethod 60 | def has_custom_action1_permission(cls, request): 61 | return cls.specific_global_allowed 62 | 63 | @classmethod 64 | def has_custom_action2_permission(cls, request): 65 | return cls.specific_global_allowed 66 | 67 | 68 | class SpecificObjectMixin(object): 69 | specific_object_allowed = True 70 | 71 | # Ignore this method. It is here to make tests easier to construct, but list requests will never occur for single objects 72 | def has_object_list_permission(self, request): 73 | return self.specific_object_allowed 74 | 75 | # Ignore this method. It is here to make tests easier to construct, but create requests will never occur for single objects 76 | def has_object_create_permission(self, request): 77 | return self.specific_object_allowed 78 | 79 | def has_object_destroy_permission(self, request): 80 | return self.specific_object_allowed 81 | 82 | def has_object_retrieve_permission(self, request): 83 | return self.specific_object_allowed 84 | 85 | def has_object_update_permission(self, request): 86 | return self.specific_object_allowed 87 | 88 | def has_object_custom_action1_permission(self, request): 89 | return self.specific_object_allowed 90 | 91 | def has_object_custom_action2_permission(self, request): 92 | return self.specific_object_allowed 93 | 94 | 95 | class DummySerializer(serializers.ModelSerializer): 96 | permissions = DRYPermissionsField(additional_actions=['custom_action1', 'custom_action2']) 97 | 98 | class Meta: 99 | model = DummyModel 100 | fields = ('test_field', 'permissions') 101 | 102 | 103 | class DummyViewSet(viewsets.ModelViewSet): 104 | permission_classes = (DRYPermissions,) 105 | queryset = DummyModel.objects.all() 106 | serializer_class = DummySerializer 107 | 108 | def dummy_check_permission(self, request, obj): 109 | for permission in self.get_permissions(): 110 | if not permission.has_permission(request, self): 111 | return False 112 | 113 | for permission in self.get_permissions(): 114 | if not permission.has_object_permission(request, self, obj): 115 | return False 116 | 117 | return True 118 | 119 | 120 | class DRYRestPermissionsTests(TestCase): 121 | 122 | def setUp(self): 123 | self.action_set = ['retrieve', 'list', 'create', 'destroy', 'update', 'partial_update', 'custom_action1', 'custom_action2'] 124 | 125 | self.factory = RequestFactory() 126 | self.request_retrieve = Request(self.factory.get('/dummy/1')) 127 | self.request_list = Request(self.factory.get('/dummy')) 128 | self.request_create = Request(self.factory.post('/dummy'), {}) 129 | self.request_destroy = Request(self.factory.delete('/dummy/1')) 130 | self.request_update = Request(self.factory.put('/dummy/1', {})) 131 | self.request_partial_update = Request(self.factory.patch('/dummy/1', {})) 132 | self.request_custom_action1 = Request(self.factory.get('/dummy/custom_action1')) 133 | self.request_custom_action2 = Request(self.factory.post('/dummy/custom_action2', {})) 134 | 135 | def _run_permission_checks(self, view, obj, assert_value): 136 | for action in self.action_set: 137 | view.action = action 138 | request_name = "request_{action}".format(action=action) 139 | result = view.dummy_check_permission(getattr(self, request_name), obj) 140 | self.assertEqual(result, assert_value) 141 | 142 | def _run_dry_permission_field_checks(self, view, obj, assert_specific, assert_base): 143 | serializer = view.get_serializer_class()() 144 | # dummy request 145 | serializer.context['request'] = self.request_retrieve 146 | representation = serializer.to_representation(obj) 147 | 148 | for action in [action for action in self.action_set if action not in ['partial_update', 'list']]: 149 | has_permission = representation['permissions'].get(action, None) 150 | self.assertEqual(has_permission, assert_specific, "Action '%s' %s != %s" % (action, has_permission, assert_specific)) 151 | 152 | for action in ['read', 'write']: 153 | has_permission = representation['permissions'].get(action, None) 154 | self.assertEqual(has_permission, assert_base, "Action '%s' %s != %s" % (action, has_permission, assert_base)) 155 | 156 | def test_true_base_permissions(self): 157 | class TestModel(DummyModel, BaseObjectMixin, BaseGlobalMixin): 158 | pass 159 | 160 | class TestSerializer(DummySerializer): 161 | class Meta: 162 | model = TestModel 163 | fields = '__all__' 164 | 165 | class TestViewSet(DummyViewSet): 166 | serializer_class = TestSerializer 167 | 168 | view = TestViewSet() 169 | 170 | self._run_permission_checks(view, TestModel(), True) 171 | self._run_dry_permission_field_checks(view, TestModel(), None, True) 172 | 173 | def test_false_base_object_permissions(self): 174 | class TestModel(DummyModel, BaseObjectMixin, BaseGlobalMixin): 175 | base_object_allowed = False 176 | 177 | class TestSerializer(DummySerializer): 178 | class Meta: 179 | model = TestModel 180 | fields = '__all__' 181 | 182 | class TestViewSet(DummyViewSet): 183 | serializer_class = TestSerializer 184 | 185 | view = TestViewSet() 186 | 187 | self._run_permission_checks(view, TestModel(), False) 188 | self._run_dry_permission_field_checks(view, TestModel(), None, False) 189 | 190 | def test_false_base_global_permissions(self): 191 | class TestModel(DummyModel, BaseObjectMixin, BaseGlobalMixin): 192 | base_global_allowed = False 193 | 194 | class TestSerializer(DummySerializer): 195 | class Meta: 196 | model = TestModel 197 | fields = '__all__' 198 | 199 | class TestViewSet(DummyViewSet): 200 | serializer_class = TestSerializer 201 | 202 | view = TestViewSet() 203 | 204 | self._run_permission_checks(view, TestModel(), False) 205 | self._run_dry_permission_field_checks(view, TestModel(), None, False) 206 | 207 | def test_true_specific_permissions(self): 208 | class TestModel( 209 | DummyModel, BaseObjectMixin, BaseGlobalMixin, 210 | SpecificObjectMixin, SpecificGlobalMixin): 211 | base_global_allowed = False 212 | base_object_allowed = False 213 | 214 | class TestSerializer(DummySerializer): 215 | class Meta: 216 | model = TestModel 217 | fields = '__all__' 218 | 219 | class TestViewSet(DummyViewSet): 220 | serializer_class = TestSerializer 221 | 222 | view = TestViewSet() 223 | 224 | self._run_permission_checks(view, TestModel(), True) 225 | self._run_dry_permission_field_checks(view, TestModel(), True, False) 226 | 227 | def test_true_base_not_defined_permissions(self): 228 | class TestModel(DummyModel, SpecificObjectMixin, SpecificGlobalMixin): 229 | pass 230 | 231 | class TestSerializer(DummySerializer): 232 | class Meta: 233 | model = TestModel 234 | fields = '__all__' 235 | 236 | class TestViewSet(DummyViewSet): 237 | serializer_class = TestSerializer 238 | 239 | view = TestViewSet() 240 | 241 | self._run_permission_checks(view, TestModel(), True) 242 | self._run_dry_permission_field_checks(view, TestModel(), True, None) 243 | 244 | def test_false_specific_object_permissions(self): 245 | class TestModel( 246 | DummyModel, BaseObjectMixin, BaseGlobalMixin, 247 | SpecificObjectMixin, SpecificGlobalMixin): 248 | specific_object_allowed = False 249 | 250 | class TestSerializer(DummySerializer): 251 | class Meta: 252 | model = TestModel 253 | fields = '__all__' 254 | 255 | class TestViewSet(DummyViewSet): 256 | serializer_class = TestSerializer 257 | 258 | view = TestViewSet() 259 | 260 | self._run_permission_checks(view, TestModel(), False) 261 | self._run_dry_permission_field_checks(view, TestModel(), False, True) 262 | 263 | def test_false_specific_global_permissions(self): 264 | class TestModel( 265 | DummyModel, BaseObjectMixin, BaseGlobalMixin, 266 | SpecificObjectMixin, SpecificGlobalMixin): 267 | specific_global_allowed = False 268 | 269 | class TestSerializer(DummySerializer): 270 | class Meta: 271 | model = TestModel 272 | fields = '__all__' 273 | 274 | class TestViewSet(DummyViewSet): 275 | serializer_class = TestSerializer 276 | 277 | view = TestViewSet() 278 | 279 | self._run_permission_checks(view, TestModel(), False) 280 | self._run_dry_permission_field_checks(view, TestModel(), False, True) 281 | 282 | def test_true_no_global_permissions(self): 283 | class TestModel( 284 | DummyModel, BaseObjectMixin, BaseGlobalMixin, 285 | SpecificObjectMixin, SpecificGlobalMixin): 286 | base_global_allowed = False 287 | specific_global_allowed = False 288 | 289 | class TestSerializer(DummySerializer): 290 | permissions = DRYPermissionsField(object_only=True, additional_actions=['custom_action1', 'custom_action2']) 291 | 292 | class Meta: 293 | model = TestModel 294 | fields = '__all__' 295 | 296 | class TestViewSet(DummyViewSet): 297 | serializer_class = TestSerializer 298 | permission_classes = (DRYObjectPermissions, ) 299 | 300 | view = TestViewSet() 301 | 302 | self._run_permission_checks(view, TestModel(), True) 303 | self._run_dry_permission_field_checks(view, TestModel(), True, True) 304 | 305 | def test_true_no_object_permissions(self): 306 | class TestModel( 307 | DummyModel, BaseObjectMixin, BaseGlobalMixin, 308 | SpecificObjectMixin, SpecificGlobalMixin): 309 | base_object_allowed = False 310 | specific_object_allowed = False 311 | 312 | class TestSerializer(DummySerializer): 313 | permissions = DRYPermissionsField(global_only=True, additional_actions=['custom_action1', 'custom_action2']) 314 | 315 | class Meta: 316 | model = TestModel 317 | fields = '__all__' 318 | 319 | class TestViewSet(DummyViewSet): 320 | serializer_class = TestSerializer 321 | permission_classes = (DRYGlobalPermissions, ) 322 | 323 | view = TestViewSet() 324 | 325 | self._run_permission_checks(view, TestModel(), True) 326 | self._run_dry_permission_field_checks(view, TestModel(), True, True) 327 | 328 | def test_list_filter_backend(self): 329 | class DummyFilter(object): 330 | pass 331 | 332 | class TestModel(DummyModel): 333 | pass 334 | 335 | class TestSerializer(DummySerializer): 336 | 337 | class Meta: 338 | model = TestModel 339 | 340 | class TestFilterBackend(DRYPermissionFiltersBase): 341 | def filter_list_queryset(self, request, queryset, view): 342 | return DummyFilter() 343 | 344 | class TestViewSet(DummyViewSet): 345 | serializer_class = TestSerializer 346 | queryset = TestModel.objects.all() 347 | filter_backends = (TestFilterBackend,) 348 | 349 | view = TestViewSet() 350 | view.request = self.request_list 351 | view.action = 'list' 352 | view.kwargs = [] 353 | query_set = view.filter_queryset(view.get_queryset()) 354 | self.assertEqual(query_set.__class__, DummyFilter) 355 | 356 | def test_action_filter_backend(self): 357 | class DummyFilter(object): 358 | pass 359 | 360 | class TestModel(DummyModel): 361 | pass 362 | 363 | class TestSerializer(DummySerializer): 364 | 365 | class Meta: 366 | model = TestModel 367 | fields = '__all__' 368 | 369 | class TestFilterBackend(DRYPermissionFiltersBase): 370 | action_routing = True 371 | 372 | def filter_list_queryset(self, request, queryset, view): 373 | return None 374 | 375 | def filter_custom_action1_queryset(self, request, queryset, view): 376 | return DummyFilter() 377 | 378 | class TestViewSet(DummyViewSet): 379 | serializer_class = TestSerializer 380 | queryset = TestModel.objects.all() 381 | filter_backends = (TestFilterBackend,) 382 | 383 | view = TestViewSet() 384 | view.request = self.request_custom_action1 385 | view.action = 'custom_action1' 386 | view.kwargs = [] 387 | query_set = view.filter_queryset(view.get_queryset()) 388 | self.assertEqual(query_set.__class__, DummyFilter) 389 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = 3 | py27-{flake8,docs}, 4 | py{27,34,35,36}-django{1.8,1.11}-drf{3.5,3.6}, 5 | py{34,35,36}-django{2}-drf{3.7} 6 | 7 | 8 | [testenv] 9 | commands = ./runtests.py --fast 10 | setenv = 11 | PYTHONDONTWRITEBYTECODE=1 12 | deps = 13 | django1.11: Django>=1.11,<2.0 14 | django2: Django>=2.0,<3.0 15 | drf3.5: djangorestframework>=3.5,<3.6 16 | drf3.6: djangorestframework>=3.6,<3.7 17 | drf3.7: djangorestframework>=3.7,<3.8 18 | pytest-django==3.1.2 19 | 20 | [testenv:py27-flake8] 21 | commands = ./runtests.py --lintonly 22 | deps = 23 | pytest==3.0.7 24 | flake8==3.3.0 25 | 26 | [testenv:py27-docs] 27 | commands = mkdocs build 28 | deps = 29 | mkdocs>=0.11.1 30 | --------------------------------------------------------------------------------