├── django_rules ├── __init__.py ├── management │ ├── __init__.py │ └── commands │ │ ├── __init__.py │ │ └── sync_rules.py ├── tests │ ├── __init__.py │ ├── utils2.py │ ├── utils3.py │ ├── utils.py │ ├── test_settings.py │ ├── runtests.py │ ├── models.py │ ├── test_decorators.py │ └── test_core.py ├── exceptions.py ├── utils.py ├── models.py ├── decorators.py └── backends.py ├── MANIFEST.in ├── .gitignore ├── .hgignore ├── CONTRIBUTORS.txt ├── setup.py ├── LICENSE.txt └── README.textile /django_rules/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /django_rules/management/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /django_rules/management/commands/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include CONTRIBUTORS.txt 2 | include README.textile 3 | include LICENSE.txt 4 | -------------------------------------------------------------------------------- /django_rules/tests/__init__.py: -------------------------------------------------------------------------------- 1 | from test_core import * 2 | from test_decorators import * 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.hg 2 | *.py[co] 3 | *.swp 4 | *~ 5 | /nbproject 6 | /.idea 7 | /build 8 | /dist 9 | /django_rules.egg-info 10 | -------------------------------------------------------------------------------- /django_rules/tests/utils2.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | # There is a typo in the function name 4 | def centralized_authorizations(user, perm): 5 | return True 6 | -------------------------------------------------------------------------------- /django_rules/tests/utils3.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | # Wrong number of parameters 4 | def central_authorizations(user_obj, perm, extra): 5 | return True 6 | -------------------------------------------------------------------------------- /.hgignore: -------------------------------------------------------------------------------- 1 | # For people using hg-git plugin 2 | syntax: glob 3 | .git/* 4 | *.py[co] 5 | *.swp 6 | *~ 7 | nbproject/* 8 | .idea/* 9 | build/* 10 | dist/* 11 | django_rules.egg-info/* 12 | -------------------------------------------------------------------------------- /django_rules/tests/utils.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | def central_authorizations(user_obj, perm): 3 | # Central authorizations should go here 4 | if perm == "all_can_pass": 5 | return True 6 | -------------------------------------------------------------------------------- /CONTRIBUTORS.txt: -------------------------------------------------------------------------------- 1 | ============ 2 | Contributors 3 | ============ 4 | 5 | django-rules Project Lead 6 | ========================= 7 | 8 | * Miguel Araujo 9 | 10 | Contributors 11 | =========== 12 | 13 | * J. Javier Maestro 14 | * Chris Glass 15 | * Mikołaj Siedlarek 16 | -------------------------------------------------------------------------------- /django_rules/exceptions.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | Exceptions used by django-rules. All internal and rules-specific errors 4 | should extend RulesError class 5 | """ 6 | 7 | class RulesError(Exception): 8 | pass 9 | 10 | class NonexistentPermission(RulesError): 11 | pass 12 | 13 | class NonexistentFieldName(RulesError): 14 | pass 15 | 16 | class NotBooleanPermission(RulesError): 17 | pass 18 | -------------------------------------------------------------------------------- /django_rules/tests/test_settings.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | BASE_DIR = os.path.dirname(__file__) 4 | 5 | INSTALLED_APPS = ( 6 | 'django.contrib.auth', 7 | 'django.contrib.sessions', 8 | 'django.contrib.contenttypes', 9 | 'django.contrib.admin', 10 | 'django_rules', 11 | 'django_rules.tests', 12 | ) 13 | 14 | DATABASES = { 15 | 'default': { 16 | 'ENGINE': 'django.db.backends.sqlite3', 17 | } 18 | } 19 | 20 | AUTHENTICATION_BACKENDS = ( 21 | 'django.contrib.auth.backends.ModelBackend', 22 | 'django_rules.backends.ObjectPermissionBackend', 23 | ) 24 | 25 | ANONYMOUS_USER_ID = '1' 26 | -------------------------------------------------------------------------------- /django_rules/tests/runtests.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | import os, sys 4 | 5 | os.environ['DJANGO_SETTINGS_MODULE'] = 'test_settings' 6 | parent = os.path.dirname(os.path.dirname(os.path.dirname( 7 | os.path.abspath(__file__)))) 8 | 9 | sys.path.insert(0, parent) 10 | 11 | from django.test.simple import DjangoTestSuiteRunner 12 | from django.conf import settings 13 | 14 | def runtests(): 15 | DjangoTestSuiteRunner(failfast=False).run_tests([ 16 | 'django_rules.BackendTest', 17 | 'django_rules.RulePermissionTest', 18 | 'django_rules.UtilsTest', 19 | 'django_rules.DecoratorsTest' 20 | ], verbosity=1, interactive=True) 21 | 22 | if __name__ == '__main__': 23 | runtests() 24 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import os 3 | import sys 4 | reload(sys).setdefaultencoding("UTF-8") 5 | from setuptools import setup, find_packages 6 | from distutils.core import setup 7 | 8 | 9 | def read(fname): 10 | return open(os.path.join(os.path.dirname(__file__), fname)).read() 11 | 12 | version = '0.2' 13 | 14 | setup( 15 | name='django-rules', 16 | version=version, 17 | description="Flexible per-object authorization backend for Django", 18 | long_description=read('README.textile'), 19 | classifiers=[ 20 | "Programming Language :: Python", 21 | "Topic :: Software Development :: Libraries :: Python Modules", 22 | 'Intended Audience :: Developers', 23 | 'License :: OSI Approved :: BSD License', 24 | 'Operating System :: OS Independent', 25 | "Framework :: Django", 26 | "Environment :: Web Environment", 27 | ], 28 | keywords=['authorization', 'backends', 'django', 'rules', 'permissions'], 29 | author='Miguel Araujo', 30 | author_email='miguel.araujo.perez@gmail.com', 31 | url='http://github.com/maraujop/django-rules', 32 | license='BSD', 33 | packages=find_packages(), 34 | zip_safe=False, 35 | ) 36 | -------------------------------------------------------------------------------- /django_rules/tests/models.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from django.db import models 3 | from django.contrib.auth.models import User 4 | 5 | class Dummy(models.Model): 6 | """ 7 | Dummy model for testing permissions 8 | """ 9 | idDummy = models.AutoField(primary_key = True) 10 | supplier = models.ForeignKey(User, null = False) 11 | name = models.CharField(max_length = 20, null = True) 12 | 13 | def canShip(self,user_obj): 14 | """ 15 | Only the supplier can_ship in our business logic. 16 | Checks if the user_obj passed is the supplier. 17 | """ 18 | return self.supplier == user_obj 19 | 20 | @property 21 | def isDisposable(self): 22 | """ 23 | It should check some attributes to see if 24 | package is disposable 25 | """ 26 | return True 27 | 28 | def canTrash(self): 29 | """ 30 | Methods can either have a user_obj parameter 31 | or no parameters 32 | """ 33 | return True 34 | 35 | def methodInteger(self): 36 | """ 37 | This method does not return a boolean value 38 | """ 39 | return 2 40 | 41 | def invalidNumberParameters(self, param1, param2): 42 | """ 43 | This method has too many parameters for being a rule 44 | """ 45 | pass 46 | 47 | 48 | -------------------------------------------------------------------------------- /django_rules/utils.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import sys 4 | 5 | from django.contrib.contenttypes.models import ContentType 6 | 7 | from models import RulePermission 8 | 9 | def register(app_name, codename, model, field_name='', view_param_pk='', description=''): 10 | """ 11 | Call this function in your rules.py to register your RulePermissions 12 | All registered rules will be synced when sync_rules command is run 13 | """ 14 | # We get the `ContentType` for that `model` within that `app_name` 15 | try: 16 | ctype = ContentType.objects.get(app_label = app_name, model = model.lower()) 17 | except ContentType.DoesNotExist: 18 | sys.stderr.write('! Rule codenamed %s will not be synced as model %s was not found for app %s\n' % (codename, model, app_name)) 19 | return 20 | 21 | try: 22 | # We see if the rule's pk exists, if it does then delete and overwrite it 23 | rule = RulePermission.objects.get(pk = codename) 24 | rule.delete() 25 | sys.stderr.write('Careful rule %s being overwritten. Make sure its codename is not repeated in other rules.py files\n' % codename) 26 | RulePermission.objects.create(codename=codename, field_name=field_name, content_type=ctype, 27 | view_param_pk=view_param_pk, description=description) 28 | 29 | except RulePermission.DoesNotExist: 30 | RulePermission.objects.create(codename=codename, field_name=field_name, content_type=ctype, 31 | view_param_pk=view_param_pk, description=description) 32 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) Miguel Araujo and individual contributors. 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without modification, 5 | are permitted provided that the following conditions are met: 6 | 7 | 1. Redistributions of source code must retain the above copyright notice, 8 | this list of conditions and the following disclaimer. 9 | 10 | 2. Redistributions in binary form must reproduce the above copyright 11 | notice, this list of conditions and the following disclaimer in the 12 | documentation and/or other materials provided with the distribution. 13 | 14 | 3. Neither the name of django-rules nor the names of its 15 | contributors may be used to endorse or promote products derived from 16 | this software without specific prior written permission. 17 | 18 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 19 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 20 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 21 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR 22 | ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 23 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 24 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON 25 | ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 26 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 27 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 28 | -------------------------------------------------------------------------------- /django_rules/management/commands/sync_rules.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import sys, os 3 | import imp 4 | from optparse import make_option 5 | 6 | from django.conf import settings 7 | from django.utils.importlib import import_module 8 | from django.core.management import call_command 9 | from django.core.management import BaseCommand 10 | from django.db import connections 11 | 12 | 13 | def import_app(app_label, verbosity): 14 | # We get the app_path, necessary to use imp module find function 15 | try: 16 | app_path = __import__(app_label, {}, {}, [app_label.split('.')[-1]]).__path__ 17 | except AttributeError: 18 | return 19 | except ImportError: 20 | print "Unknown application: %s" % app_label 21 | print "Stopping synchronization" 22 | sys.exit(1) 23 | 24 | # imp.find_module looks for rules.py within the app 25 | # It does not import the module, but raises and ImportError 26 | # if rules.py does not exist, so we continue to next app 27 | try: 28 | imp.find_module('rules', app_path) 29 | except ImportError: 30 | return 31 | 32 | if verbosity >= 1: 33 | sys.stderr.write('Syncing rules from %s\n' % app_label) 34 | 35 | # Now we import the module, this should bubble up errors 36 | # if there are any in rules.py Warning the user 37 | generator = import_module('.rules', app_label) 38 | 39 | 40 | class Command(BaseCommand): 41 | option_list = BaseCommand.option_list + ( 42 | make_option("--fixture", action='store_true', dest="fixture", default=False, 43 | help="Generate a fixture of django_rules"), 44 | ) 45 | help = 'Syncs into database all rules defined in rules.py files' 46 | args = '[appname ...]' 47 | 48 | def handle(self, *app_labels, **options): 49 | verbosity = int(options.pop('verbosity', 1)) 50 | fixture = options.pop('fixture') 51 | 52 | if len(app_labels) == 0: 53 | # We look for a rules.py within every app in INSTALLED_APPS 54 | # We sync the rules_list against RulePermissions 55 | for app_label in settings.INSTALLED_APPS: 56 | import_app(app_label, verbosity) 57 | else: 58 | for app_label in app_labels: 59 | import_app(app_label, verbosity) 60 | 61 | if fixture: 62 | for alias in connections._connections: 63 | call_command("dumpdata", 64 | 'django_rules.rulepermission', 65 | **dict(options, verbosity=0, database=alias)) 66 | -------------------------------------------------------------------------------- /django_rules/models.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import inspect 3 | from django.core.exceptions import ValidationError 4 | from django.db import models 5 | from django.contrib.contenttypes.models import ContentType 6 | 7 | from exceptions import NonexistentFieldName 8 | from exceptions import RulesError 9 | 10 | 11 | class RulePermission(models.Model): 12 | """ 13 | This model holds the rules for the authorization system 14 | """ 15 | codename = models.CharField(primary_key=True, max_length=30) 16 | field_name = models.CharField(max_length=30) 17 | content_type = models.ForeignKey(ContentType) 18 | view_param_pk = models.CharField(max_length=30) 19 | description = models.CharField(max_length=140, null=True) 20 | 21 | 22 | def save(self, *args, **kwargs): 23 | """ 24 | Validates that the field_name exists in the content_type model 25 | raises ValidationError if it doesn't. We need to restrict security rules creation 26 | """ 27 | # If not set use codename as field_name as default 28 | if self.field_name == '': 29 | self.field_name = self.codename 30 | 31 | # If not set use primary key attribute name as default 32 | if self.view_param_pk == '': 33 | self.view_param_pk = self.content_type.model_class()._meta.pk.get_attname() 34 | 35 | # First search for a method or property defined in the model class 36 | # Then we look in the meta field_names 37 | # If field_name does not exist a ValidationError is raised 38 | if not hasattr(self.content_type.model_class(), self.field_name): 39 | # Search within attributes field names 40 | if not (self.field_name in self.content_type.model_class()._meta.get_all_field_names()): 41 | raise NonexistentFieldName("Could not create rule: field_name %s of rule %s does not exist in model %s" % 42 | (self.field_name, self.codename, self.content_type.model)) 43 | else: 44 | # Check if the method parameters are less than 2 including self in the count 45 | bound_field = getattr(self.content_type.model_class(), self.field_name) 46 | if callable(bound_field): 47 | if len(inspect.getargspec(bound_field)[0]) > 2: 48 | raise RulesError("method %s from rule %s in model %s has too many parameters." % 49 | (self.field_name, self.codename, self.content_type.model)) 50 | 51 | super(RulePermission, self).save(*args, **kwargs) 52 | -------------------------------------------------------------------------------- /django_rules/tests/test_decorators.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from django.test import TestCase 3 | from django.contrib.auth.models import User, AnonymousUser 4 | from django.contrib.contenttypes.models import ContentType 5 | from django.http import HttpRequest, HttpResponse, Http404, HttpResponseRedirect 6 | 7 | from django_rules.exceptions import RulesError, NonexistentPermission 8 | from django_rules.models import RulePermission 9 | from django_rules.decorators import object_permission_required 10 | from models import Dummy 11 | 12 | class DecoratorsTest(TestCase): 13 | def setUp(self): 14 | self.user = User.objects.get_or_create(username='javier', is_active=True)[0] 15 | self.otheruser = User.objects.get_or_create(username='miguel', is_active=True)[0] 16 | self.obj = Dummy.objects.get_or_create(idDummy=1,supplier=self.user)[0] 17 | self.ctype = ContentType.objects.get_for_model(self.obj) 18 | self.rule = RulePermission.objects.get_or_create(codename='can_ship', field_name='canShip', content_type=self.ctype, view_param_pk='idView', 19 | description="Only supplier have the authorization to ship") 20 | self.wrong_pk_rule = RulePermission.objects.get_or_create(codename='can_supply', field_name='canShip', content_type=self.ctype, view_param_pk='nonexistent_param', 21 | description="view_param_pk does not match idView param from dummy_view") 22 | 23 | def _get_request(self, user=None): 24 | if user is None: 25 | user = AnonymousUser() 26 | request = HttpRequest() 27 | request.user = user 28 | return request 29 | 30 | def _dummy_view(self, user_obj, dicc, value): 31 | @object_permission_required(**dicc) 32 | def dummy_view(request, idView): 33 | return HttpResponse('success') 34 | 35 | request = self._get_request(user_obj) 36 | return dummy_view(request, idView=value) 37 | 38 | 39 | def test_no_args(self): 40 | try: 41 | @object_permission_required 42 | def dummy_view(request): 43 | return HttpResponse('dummy_view') 44 | except RulesError: 45 | pass 46 | else: 47 | self.fail("Trying to decorate using permission_required without permission as first argument should raise exception") 48 | 49 | def test_wrong_args(self): 50 | self.assertRaises(RulesError, lambda:self._dummy_view(self.user,{'perm':2},self.obj.pk)) 51 | 52 | def test_with_permission(self): 53 | response = self._dummy_view(self.user, {'perm':'can_ship'}, self.obj.pk) 54 | self.assertEqual(response.content, 'success') 55 | 56 | def test_without_permission_403(self): 57 | response = self._dummy_view(self.otheruser, {'perm':'can_ship','return_403':True}, self.obj.pk) 58 | self.assertEqual(response.status_code, 403) 59 | 60 | def test_nonexistent_permission(self): 61 | self.assertRaises(NonexistentPermission, lambda: self._dummy_view(self.user, {'perm':'nonexistent_perm'}, self.obj.pk)) 62 | 63 | def test_nonexistent_obj(self): 64 | last=int(Dummy.objects.latest(field_name='pk').pk) 65 | self.assertRaises(Http404, lambda: self._dummy_view(self.user, {'perm':'can_ship'}, last+1)) 66 | 67 | def test_without_permission_redirection(self): 68 | response = self._dummy_view(self.otheruser, {'perm':'can_ship','login_url':'/foobar/'}, self.obj.pk) 69 | self.assertTrue(isinstance(response, HttpResponseRedirect)) 70 | self.assertTrue(response._headers['location'][1].startswith('/foobar/')) 71 | 72 | def test_view_param_pk_not_match_param_in_view(self): 73 | self.assertRaises(RulesError, lambda: self._dummy_view(self.user, {'perm':'can_supply'}, self.obj.pk)) 74 | 75 | 76 | -------------------------------------------------------------------------------- /django_rules/decorators.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from django.shortcuts import get_object_or_404 3 | from django.utils.http import urlquote 4 | from django.conf import settings 5 | from django.contrib.auth import REDIRECT_FIELD_NAME 6 | from django.http import HttpResponseForbidden 7 | from django.http import HttpResponseRedirect 8 | from django.utils.functional import wraps 9 | from django.shortcuts import get_object_or_404 10 | from django.core.urlresolvers import NoReverseMatch, reverse 11 | 12 | from exceptions import RulesError 13 | from exceptions import NonexistentPermission 14 | from models import RulePermission 15 | from backends import ObjectPermissionBackend 16 | 17 | 18 | def object_permission_required(perm, **kwargs): 19 | """ 20 | Decorator for views that checks whether a user has a particular permission 21 | 22 | The view needs to have a parameter name that matches rule's view_param_pk. 23 | The value of this parameter will be taken as the primary key of the model. 24 | 25 | :param login_url: if denied, user would be redirected to location set by 26 | this parameter. Defaults to ``django.conf.settings.LOGIN_URL``. 27 | :param redirect_field_name: name of the parameter passed if redirected. 28 | Defaults to ``django.contrib.auth.REDIRECT_FIELD_NAME``. 29 | :param return_403: if set to ``True`` then instead of redirecting to the 30 | login page, response with status code 403 is returned ( 31 | ``django.http.HttpResponseForbidden`` instance). Defaults to ``False``. 32 | 33 | Examples:: 34 | 35 | # RulePermission.objects.get_or_create(codename='can_ship',...,view_param_pk='paramView') 36 | @permission_required('can_ship', return_403=True) 37 | def my_view(request, paramView): 38 | return HttpResponse('Hello') 39 | 40 | """ 41 | 42 | login_url = kwargs.pop('login_url', settings.LOGIN_URL) 43 | redirect_url = kwargs.pop('redirect_url', "") 44 | redirect_field_name = kwargs.pop('redirect_field_name', REDIRECT_FIELD_NAME) 45 | return_403 = kwargs.pop('return_403', False) 46 | 47 | # Check if perm is given as string in order to not decorate 48 | # view function itself which makes debugging harder 49 | if not isinstance(perm, basestring): 50 | raise RulesError("First argument, permission, must be a string") 51 | 52 | def decorator(view_func): 53 | def _wrapped_view(request, *args, **kwargs): 54 | obj = None 55 | 56 | try: 57 | rule = RulePermission.objects.get(codename = perm) 58 | except RulePermission.DoesNotExist: 59 | raise NonexistentPermission("Permission %s does not exist" % perm) 60 | 61 | # Only look in kwargs, if the views are entry points through urls Django passes parameters as kwargs 62 | # We could look in args using inspect.getcallargs in Python 2.7 or a custom function that 63 | # imitates it, but if the view is internal, I think it's better to force the user to pass 64 | # parameters as kwargs 65 | if rule.view_param_pk not in kwargs: 66 | raise RulesError("The view does not have a parameter called %s in kwargs" % rule.view_param_pk) 67 | 68 | model_class = rule.content_type.model_class() 69 | obj = get_object_or_404(model_class, pk=kwargs[rule.view_param_pk]) 70 | 71 | if not request.user.has_perm(perm, obj): 72 | if return_403: 73 | return HttpResponseForbidden() 74 | else: 75 | if redirect_url: 76 | try: 77 | path = urlquote(request.get_full_path()) 78 | redirect_url_reversed = reverse(redirect_url) 79 | tup = redirect_url_reversed, redirect_field_name, path 80 | except NoReverseMatch: 81 | tup = redirect_url, redirect_field_name, path 82 | else: 83 | path = urlquote(request.get_full_path()) 84 | tup = login_url, redirect_field_name, path 85 | 86 | return HttpResponseRedirect("%s?%s=%s" % tup) 87 | return view_func(request, *args, **kwargs) 88 | return wraps(view_func)(_wrapped_view) 89 | return decorator 90 | -------------------------------------------------------------------------------- /django_rules/backends.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import inspect 3 | 4 | from django.conf import settings 5 | from django.contrib.contenttypes.models import ContentType 6 | from django.contrib.auth.models import User, AnonymousUser 7 | from django.utils.importlib import import_module 8 | 9 | from models import RulePermission 10 | from exceptions import NotBooleanPermission 11 | from exceptions import NonexistentFieldName 12 | from exceptions import NonexistentPermission 13 | from exceptions import RulesError 14 | 15 | 16 | class ObjectPermissionBackend(object): 17 | supports_object_permissions = True 18 | supports_anonymous_user = True 19 | supports_inactive_user = True 20 | 21 | def authenticate(self, username, password): 22 | return None 23 | 24 | def has_perm(self, user_obj, perm, obj=None): 25 | """ 26 | This method checks if the user_obj has perm on obj. Returns True or False 27 | Looks for the rule with the code_name = perm and the content_type of the obj 28 | If it exists returns the value of obj.field_name or obj.field_name() in case 29 | the field is a method. 30 | """ 31 | 32 | if obj is None: 33 | return False 34 | 35 | if not user_obj.is_authenticated(): 36 | user_obj = User.objects.get(pk=settings.ANONYMOUS_USER_ID) 37 | 38 | # Centralized authorizations 39 | # You need to define a module in settings.CENTRAL_AUTHORIZATIONS that has a 40 | # central_authorizations function inside 41 | if hasattr(settings, 'CENTRAL_AUTHORIZATIONS'): 42 | module = getattr(settings, 'CENTRAL_AUTHORIZATIONS') 43 | 44 | try: 45 | mod = import_module(module) 46 | except ImportError, e: 47 | raise RulesError('Error importing central authorizations module %s: "%s"' % (module, e)) 48 | 49 | try: 50 | central_authorizations = getattr(mod, 'central_authorizations') 51 | except AttributeError: 52 | raise RulesError('Error module %s does not have a central_authorization function"' % (module)) 53 | 54 | try: 55 | is_authorized = central_authorizations(user_obj, perm) 56 | # If the value returned is a boolean we pass it up and stop checking 57 | # If not, we continue checking 58 | if isinstance(is_authorized, bool): 59 | return is_authorized 60 | 61 | except TypeError: 62 | raise RulesError('central_authorizations should receive 2 parameters: (user_obj, perm)') 63 | 64 | # Note: 65 | # is_active and is_superuser are checked by default in django.contrib.auth.models 66 | # lines from 301-306 in Django 1.2.3 67 | # If this checks dissapear in mainstream, tests will fail, so we won't double check them :) 68 | ctype = ContentType.objects.get_for_model(obj) 69 | 70 | # We get the rule data and return the value of that rule 71 | try: 72 | rule = RulePermission.objects.get(codename = perm, content_type = ctype) 73 | except RulePermission.DoesNotExist: 74 | return False 75 | 76 | bound_field = None 77 | try: 78 | bound_field = getattr(obj, rule.field_name) 79 | except AttributeError: 80 | raise NonexistentFieldName("Field_name %s from rule %s does not longer exist in model %s. \ 81 | The rule is obsolete!", (rule.field_name, rule.codename, rule.content_type.model)) 82 | 83 | if not isinstance(bound_field, bool) and not callable(bound_field): 84 | raise NotBooleanPermission("Attribute %s from model %s on rule %s does not return a boolean value", 85 | (rule.field_name, rule.content_type.model, rule.codename)) 86 | 87 | if not callable(bound_field): 88 | is_authorized = bound_field 89 | else: 90 | # Otherwise it is a callabe bound_field 91 | # Let's see if we pass or not user_obj as a parameter 92 | if (len(inspect.getargspec(bound_field)[0]) == 2): 93 | is_authorized = bound_field(user_obj) 94 | else: 95 | is_authorized = bound_field() 96 | 97 | if not isinstance(is_authorized, bool): 98 | raise NotBooleanPermission("Callable %s from model %s on rule %s does not return a boolean value", 99 | (rule.field_name, rule.content_type.model, rule.codename)) 100 | 101 | return is_authorized 102 | -------------------------------------------------------------------------------- /django_rules/tests/test_core.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from django.test import TestCase 3 | from django.contrib.auth.models import User, AnonymousUser 4 | from django.contrib.contenttypes.models import ContentType 5 | from django.core.exceptions import ValidationError 6 | from django.conf import settings 7 | 8 | from django_rules.models import RulePermission 9 | from models import Dummy 10 | from django_rules.exceptions import NonexistentFieldName 11 | from django_rules.exceptions import NotBooleanPermission 12 | from django_rules.exceptions import NonexistentPermission 13 | from django_rules.exceptions import RulesError 14 | from django_rules import utils 15 | 16 | class BackendTest(TestCase): 17 | def setUp(self): 18 | try: 19 | self.anonymous = User.objects.get_or_create(id=settings.ANONYMOUS_USER_ID, username='anonymous', is_active=True)[0] 20 | except Exception: 21 | self.fail("You need to define an ANONYMOUS_USER_ID in your settings file") 22 | 23 | self.user = User.objects.get_or_create(username='javier', is_active=True)[0] 24 | self.otherUser = User.objects.get_or_create(username='juan', is_active=True)[0] 25 | self.superuser = User.objects.get_or_create(username='miguel', is_active=True, is_superuser=True)[0] 26 | self.not_active_superuser = User.objects.get_or_create(username='rebeca', is_active=False, is_superuser=True)[0] 27 | self.obj = Dummy.objects.get_or_create(supplier=self.user)[0] 28 | self.ctype = ContentType.objects.get_for_model(self.obj) 29 | 30 | self.rule = RulePermission.objects.get_or_create(codename='can_ship', field_name='canShip', content_type=self.ctype, view_param_pk='idDummy', 31 | description="Only supplier have the authorization to ship")[0] 32 | 33 | 34 | def test_regularuser_has_perm(self): 35 | self.assertTrue(self.user.has_perm('can_ship', self.obj)) 36 | 37 | def test_regularuser_has_not_perm(self): 38 | self.assertFalse(self.otherUser.has_perm('can_ship', self.obj)) 39 | 40 | def test_regularuser_has_property_perm(self): 41 | """ 42 | Checks that the backend can work with properties 43 | """ 44 | RulePermission.objects.get_or_create(codename='can_trash', field_name='isDisposable', content_type=self.ctype, view_param_pk='idDummy', 45 | description="Checks if a user can trash a package") 46 | 47 | try: 48 | self.user.has_perm('can_trash',self.obj) 49 | except: 50 | self.fail("Something when wrong when checking a property rule") 51 | 52 | def test_superuser_has_perm(self): 53 | self.assertTrue(self.superuser.has_perm('invented_perm', self.obj)) 54 | 55 | def test_object_none(self): 56 | self.assertFalse(self.user.has_perm('can_ship')) 57 | 58 | def test_anonymous_user(self): 59 | anonymous_user = AnonymousUser() 60 | self.assertFalse(anonymous_user.has_perm('can_ship', self.obj)) 61 | 62 | def test_not_active_superuser(self): 63 | self.assertFalse(self.not_active_superuser.has_perm('can_ship', self.obj)) 64 | 65 | def test_nonexistent_perm(self): 66 | self.assertFalse(self.user.has_perm('nonexistent_perm', self.obj)) 67 | 68 | def test_nonboolean_attribute(self): 69 | RulePermission.objects.get_or_create(codename='wrong_rule', field_name='name', content_type=self.ctype, view_param_pk='idDummy', 70 | description="Wrong rule. The field_name exists so It is created, but it does not return True or False") 71 | 72 | self.assertRaises(NotBooleanPermission, lambda:self.user.has_perm('wrong_rule', self.obj)) 73 | 74 | def test_nonboolean_method(self): 75 | RulePermission.objects.get_or_create(codename='wrong_rule', field_name='methodInteger', content_type=self.ctype, view_param_pk='idDummy', 76 | description="Wrong rule. The field_name exists so It is created, but it does not return True or False") 77 | 78 | self.assertRaises(NotBooleanPermission, lambda:self.user.has_perm('wrong_rule', self.obj)) 79 | 80 | def test_nonexistent_field_name(self): 81 | # Dinamycally removing canShip from class Dummy to test an already existent rule that doesn't have a valid field_name anymore 82 | fun = Dummy.canShip 83 | del Dummy.canShip 84 | self.assertRaises(NonexistentFieldName, lambda:self.user.has_perm('can_ship', self.obj)) 85 | Dummy.canShip = fun 86 | 87 | def test_has_perm_method_no_parameters(self): 88 | RulePermission.objects.get_or_create(codename='canTrash', field_name='canTrash', content_type=self.ctype, view_param_pk='idDummy', 89 | description="Rule created from a method that gets no parameters") 90 | 91 | self.assertTrue(self.user.has_perm('canTrash', self.obj)) 92 | 93 | def test_central_authorizations_right_module_checked_within(self): 94 | settings.CENTRAL_AUTHORIZATIONS = 'utils' 95 | self.assertTrue(self.otherUser.has_perm('all_can_pass', self.obj)) 96 | del settings.CENTRAL_AUTHORIZATIONS 97 | 98 | def test_central_authorizations_right_module_passes_over(self): 99 | settings.CENTRAL_AUTHORIZATIONS = 'utils' 100 | self.assertFalse(self.otherUser.has_perm('can_ship', self.obj)) 101 | del settings.CENTRAL_AUTHORIZATIONS 102 | 103 | def test_central_authorizations_wrong_module(self): 104 | settings.CENTRAL_AUTHORIZATIONS = 'noexistent' 105 | self.assertRaises(RulesError, lambda:self.user.has_perm('can_ship', self.obj)) 106 | del settings.CENTRAL_AUTHORIZATIONS 107 | 108 | def test_central_authorizations_right_module_nonexistent_function(self): 109 | settings.CENTRAL_AUTHORIZATIONS = 'utils2' 110 | self.assertRaises(RulesError, lambda:self.user.has_perm('can_ship', self.obj)) 111 | del settings.CENTRAL_AUTHORIZATIONS 112 | 113 | def test_central_authorizations_right_module_wrong_number_parameters(self): 114 | settings.CENTRAL_AUTHORIZATIONS = 'utils3' 115 | self.assertRaises(RulesError, lambda:self.user.has_perm('can_ship', self.obj)) 116 | del settings.CENTRAL_AUTHORIZATIONS 117 | 118 | 119 | class RulePermissionTest(TestCase): 120 | def setUp(self): 121 | self.user = User.objects.get_or_create(username='javier', is_active=True)[0] 122 | self.obj = Dummy.objects.get_or_create(supplier=self.user)[0] 123 | self.ctype = ContentType.objects.get_for_model(self.obj) 124 | 125 | def test_invalid_field_name(self): 126 | self.assertRaises(NonexistentFieldName, lambda:RulePermission.objects.get_or_create(codename='can_ship', field_name='invalidField', content_type=self.ctype, 127 | view_param_pk='idDummy', description="Only supplier have the authorization to ship")) 128 | 129 | def test_invalid_field_name(self): 130 | self.assertRaises(NonexistentFieldName, lambda:RulePermission.objects.get_or_create(codename='can_ship', field_name='invalidField', content_type=self.ctype, 131 | view_param_pk='idDummy', description="Only supplier have the authorization to ship")) 132 | 133 | def test_valid_attribute(self): 134 | self.assertTrue(RulePermission.objects.get_or_create(codename='can_ship', field_name='supplier', content_type=self.ctype, 135 | view_param_pk='idDummy', description="Only supplier have the authorization to ship")[1]) 136 | 137 | def test_method_with_parameter(self): 138 | self.assertTrue(RulePermission.objects.get_or_create(codename='can_ship', field_name='canShip', content_type=self.ctype, 139 | view_param_pk='idDummy', description="Only supplier have the authorization to ship")[1]) 140 | 141 | def test_method_no_parameters(self): 142 | self.assertTrue(RulePermission.objects.get_or_create(codename='can_trash', field_name='canTrash', content_type=self.ctype, 143 | view_param_pk='idDummy', description="User can trash a package")[1]) 144 | 145 | def test_method_wrong_number_parameters(self): 146 | self.assertRaises(RulesError, lambda:RulePermission.objects.get_or_create(codename='can_trash', field_name='invalidNumberParameters', content_type=self.ctype, 147 | view_param_pk='idDummy', description="Rule should not be created, too many parameters")) 148 | 149 | 150 | class UtilsTest(TestCase): 151 | def test_register_valid_rules(self): 152 | rules_list = [ 153 | # Dummy model 154 | {'codename':'can_ship', 'model':'Dummy', 'field_name':'canShip', 'view_param_pk':'idView', 'description':"Only supplier has the authorization to ship"}, 155 | ] 156 | 157 | try: 158 | for params in rules_list: 159 | utils.register(app_name="tests", **params) 160 | except Exception: 161 | self.fail("test_register_valid_rules failed") 162 | 163 | def test_register_invalid_rules_NonexistentFieldName(self): 164 | rules_list = [ 165 | # Dummy model 166 | {'codename':'can_ship', 'model':'Dummy', 'field_name':'canSship', 'view_param_pk':'idView', 'description':"Only supplier has the authorization to ship"}, 167 | ] 168 | 169 | for params in rules_list: 170 | self.assertRaises(NonexistentFieldName, lambda: utils.register(app_name="tests", **params)) 171 | 172 | def test_register_valid_rules_compact_style(self): 173 | rules_list = [ 174 | # Dummy model 175 | {'codename':'canShip', 'model':'Dummy'}, 176 | ] 177 | 178 | try: 179 | for params in rules_list: 180 | utils.register(app_name="tests", **params) 181 | except Exception: 182 | self.fail("test_register_valid_rules_compact_style failed") 183 | 184 | 185 | -------------------------------------------------------------------------------- /README.textile: -------------------------------------------------------------------------------- 1 | h1(#abstract). django-rules 2 | 3 | django-rules is a Django authorization backend that offers a unified, per-object authorization management. It is quite different from other authorization backends in the way it lets you flexibly manage per-object permissions. 4 | 5 | In django-rules every rule adds an authorization constraint to a given model. The authorization constraint will check if the given user complies with the constraint (e.g. if the user has the right permissions to execute a certain action over an object, etc.). The authorization constraint can be a boolean attribute, property or method of the model, whichever you prefer for each rule :) 6 | 7 | 8 | h2(#philosophy). Philosophy 9 | 10 | django-rules strives to build a flexible and scalable authorization backend. Why is it better than other authorization backends out there? 11 | * The backend is simple, concise and compact. Less lines of code mean less complexity, faster execution and (hopefully :) less errors and bugs. 12 | * You can implement each authorization constraint as a boolean attribute, property or method of the model, whichever you prefer for each rule. This way you will be able to re-implement how authorizations work at any time. It is dynamic and you know dynamic sounds way better than static :) 13 | * You don't have to add extra permissions or groups to your users. You simply program the constraints however you like them to be and then you assign them to the rules. Done! 14 | * You have fine granularity control over how the rules handle the authentication: one rule can be using an authorization constraint that uses LDAP while other rules call a web service (or anything you wish to hook in the authorization constraint). 15 | * Other per-object authorization backends create a row in a table for every combination of object, user and permission. Even with an average-size site, you will have scalability nightmares, no matter how much you cache. 16 | * Other authorization backends have to SELECT all permissions that a user has even if you only need to check one specific permission, making the memory footprint bigger. 17 | * Other authorization backends don't have a way to set centralized permissions, which are a real necessity in most projects out there. 18 | 19 | 20 | h2(#requirements). Requirements 21 | 22 | django-rules requires a proper installation of Django 1.2 (at least). 23 | 24 | 25 | h2(#installation). Installation 26 | 27 | h3(#pypi). From Pypi 28 | 29 | As simple as doing: 30 | 31 |
 32 | pip install django-rules
 33 | 
34 | 35 | h3(#source). From source 36 | 37 | To install django-rules from source: 38 | 39 |
 40 | git clone https://github.com/maraujop/django-rules/
 41 | cd django-rules
 42 | python setup.py install
 43 | 
44 | 45 | 46 | h2(#configuration). Configuration 47 | 48 | For django-rules to work, you have to hook it into your project: 49 | 50 | * Add it to the list of INSTALLED_APPS in settings.py: 51 | 52 |
 53 | INSTALLED_APPS = (
 54 |     ...
 55 |     'django_rules',
 56 | )
 57 | 
58 | 59 | * Add the django-rules authorization backend to the list of AUTHENTICATION_BACKENDS in settings.py: 60 | 61 |
 62 | AUTHENTICATION_BACKENDS = (
 63 |     'django.contrib.auth.backends.ModelBackend', # Django's default auth backend
 64 |     'django_rules.backends.ObjectPermissionBackend',
 65 | )
 66 | 
67 | 68 | * Run syncdb to update the database with the new django-rules models: 69 | 70 |
 71 | python manage.py syncdb
 72 | 
73 | 74 | h2(#rules). Rules 75 | 76 | A rule represents a functional authorization constraint that restricts the actions that a certain user can carry out on a certain object (an instance of a Model). 77 | 78 | Every rule definition is composed of 6 parameters (3 compulsory and 3 optional): 79 | * app_name: The name of the app to which the rule applies. 80 | * codename: The name of the rule, _unique across all applications_. It should be a brief but distinctive name. 81 | * model: The name of the model associated with the rule. 82 | 83 | * field_name _(optional)_: The name of the boolean attribute, property or method of the model that implements the authorization constraint. If not set, it defaults to the codename (that is, it will look for a field named exactly like the rule). 84 | * view_param_pk _(optional)_: The view parameter's name to use for getting the primary key of the model. It is used in the decorated views for getting the actual instance of the model, that is, the object against which the authorizations will be checked. If not set, it defaults to the name of the primary key field in the model. Note that if the name of the parameter of the view that holds the value of the object's primary key doesn't match the name of the primary key of the model, the new name must be specified in this parameter (we will talk about this special case in "the section on Decorators":#decorators). 85 | * description _(optional)_: A brief (140 characters maximum) description explaining the expected behaviour of the authorization constraint. Although optional, it is considered a Good Practice ^TM^ and should always be used. 86 | 87 | The rules should be created per-Django application. That is, under the root directory of the Django-application in which you want to create rules, you should have a rules.py containing only the declarations of those rules specific to that Django-application. 88 | 89 | Once you have defined the rules in rules.py, you will want to activate them. For every rule that you want to activate you *must* add a registration point for it by calling django_rules.utils.register in rules.py. 90 | 91 | Finally, once you have rules.py properly set up, you will want to sync the rules to the database. In your Django project you will have to run sync_rules command: 92 | 93 |
 94 | python manage.py sync_rules
 95 | 
96 | 97 | This command will look for all your rules.py files under your INSTALLED_APPS and will sync the latest changes to the database, so you don't have to run syncdb or rebuild the full database at all. 98 | 99 | 100 | h2(#examples). Examples: 101 | 102 | 103 | h3(#ex1). Example 1: Creating a simple, compact rule for the Item model in the 'shipping' Django-application 104 | 105 | Let's image that, within the shipping Django-application, I have the following models.py: 106 | 107 |
108 | from django.db import models
109 | from django.contrib.auth.models import User
110 | 
111 | class Item(models.Model):
112 |     supplier = models.ForeignKey(User)
113 |     description = models.CharField(max_length = 50)
114 | 
115 | 116 | Then, imagine that the business logic in our application has a functional authorization constraint for every item such as "An item can only be shipped by its supplier". Now, to comply with the functional authorization constraint we only have to create a simple rule. 117 | 118 | First, let's start by adding an authorization constraint to the Item model. Remember that we can use a method, a boolean attribute or a boolean-returning property. This time we will be using a method: 119 | 120 |
121 | from django.db import models
122 | from django.contrib.auth.models import User
123 | 
124 | class Item(models.Model):
125 |     supplier = models.ForeignKey(User)
126 |     description = models.CharField(max_length = 50)
127 | 
128 |     def can_ship(self, user_obj):
129 |         """
130 |         Checks if the given user_obj is the supplier of the item
131 |         """
132 |         return self.supplier == user_obj
133 | 
134 | 135 | Then, to associate the authorization constraint with a rule, we have to set up the rule in the application's rules.py file: 136 | 137 |
138 | from django_rules import utils
139 | 
140 | rules_list = [
141 |     {'codename':'can_ship', 'model':'Item'},
142 | ]
143 | # NOTE:
144 | # Although the above rule definition follows the minimal style, it is
145 | # Good Practice ^TM^ to always add the optional 'description' field 
146 | # to give a brief explanation about the expected behaviour of the rule.
147 | 
148 | 
149 | # For the rules to be active, we *must* register them:
150 | for rule in rules_list:
151 |     utils.register(app_name='shipping', **rule)
152 | 
153 | 154 | Finally, do not forget to sync the rules to make sure that all the new definitions, changes, etc. are synced to the database. 155 | 156 |
157 | python manage.py sync_rules
158 | 
159 | 160 | 161 | h3(#ex2). Example 2: Creating a rule that doesn't follow the naming conventions 162 | 163 | Imagine that we would like to name our authorization constraints however we want. For example, let's change the previous Item model: 164 | 165 |
166 | from django.db import models
167 | from django.contrib.auth.models import User
168 | 
169 | class Item(models.Model):
170 |     supplier = models.ForeignKey(User)
171 |     description = models.CharField(max_length = 50)
172 | 
173 |     def isSameSupplier(self, user_obj):    #--> change in the name convention of the authorization constraint
174 |         """
175 |         Checks if the given user_obj is the supplier of the item
176 |         """
177 |         return self.supplier == user_obj
178 | 
179 | 180 | Then, we would have to set up a more verbose rule in the application's rules.py file by using its additional optional fields. This time, only field_name would be needed but it is also Good Practice ^TM^ to give a brief description: 181 | 182 |
183 | from django_rules import utils
184 | 
185 | rules_list = [
186 |     {'codename':'can_ship', 'model':'Item', 'field_name':'isSameSupplier',
187 |      'description':'Checks if the given user is the supplier of the item'},
188 | ]
189 | 
190 | # For the rules to be active, we *must* register them:
191 | for rule in rules_list:
192 |     utils.register(app_name='shipping', **rule)
193 | 
194 | 195 | Again, do not forget to sync the rules to make sure that all the new definitions, changes, etc. are applied to the database. 196 | 197 |
198 | python manage.py sync_rules
199 | 
200 | 201 | 202 | h3. Using your rules 203 | 204 | Once you have set up a rule that implements a functional authorization constraint, you can (and should :) use it in your application. It is really simple! In every place you want to enforce an authorization constraint on a user, you will simply make the following call: 205 | 206 |
207 | user_obj.has_perm(codename, model_obj)
208 | 
209 | 210 | Following the previous "Example 1":#ex1, let's imagine that the application is already running with data in the database (at least one supplier and one item, both with ids equal to 1). Remember that we have already implemented, defined, registered and synced the following rule: 211 | 212 |
213 | {'codename':'can_ship', 'model':'Item'}
214 | 
215 | 216 | Then, if we wanted to check whether a supplier can ship an item, we would only have to enforce the rule by doing: 217 | 218 |
219 | supplier = Supplier.objects.get(pk = 1)
220 | item = Item.objects.get(pk = 1)
221 | 
222 | if supplier.has_perm('can_ship', item):
223 |     print 'Yay! The supplier can ship the item! :)'
224 | 
225 | 226 | Easy, right? :) 227 | 228 | 229 | h4. Details about the internal magic of django-rules 230 | 231 | Please note that what follows is a detailed explanation of how all the inner magic in django-rules flows. If you don't really care, please move along. You really don't need these details to be able to write rules and use django-rules effectively. However, if you are curious and want to know more, please pay great attention to the details below. 232 | 233 | Here is how all the pieces of the puzzle come together: 234 | * When you call user_obj.has_perm(codename, model_obj) (in the previous example, supplier.has_perm('can_ship', item)), Django handles the control over to the django-rules backend. 235 | * The django-rules backend will then try to match the codename with a rule. Note that we are requesting a rule with a codename of 'can_ship' and a model_obj like the Model of the item object. Because in "Example 1":#ex1 we have defined the rule {'codename':'can_ship', 'model':'Item'}, there will be a match. 236 | * Then, django-rules will check whether field_name is an attribute, a property or a method, and will act accordingly. If field_name is a method, the django-rules backend will check if it requires just one user parameter or no parameter at all. Depending on the parameter requirements, it will execute model_obj.field_name() or model_obj.field_name(user_obj). In our "Example 1":#ex1 we require a user parameter so it will execute item.can_ship(supplier). 237 | * Finally, if the authorization constraint implemented in field_name is True or returns True, the constraint is considered fulfilled. Otherwise, you will not be authorized. 238 | 239 | 240 | h3. Details of using model methods in rules 241 | 242 | As we have seen, django-rules will check whether field_name is an attribute, a property or a method, and will act accordingly. That is, for the very simple cases, you can create rules based on the attributes and properties of a model. But in real life applications most of the time you will probably be setting field_name to a method in the model. 243 | 244 | It is important to note that this method is limited to having just one parameter (a user object) or no parameters at all. It cannot receive multiple arguments or an argument that is not an instance of User. Although this might seem like a limitation, we could not think of a use case where the rest of the information needed coudn't be retrieved from the user or the model object. If you get into a situation where this is limiting you, please get in touch and explain your problem so that we can think of how to get around it! :) 245 | 246 | Finally, you need to be aware of something very important: a method assigned to a rule (let's call them _rule methods_ from now on) *should never call any other method*. That is, they should be self-contained. This is to avoid a potential infinite recursion. Imagine a situation where the rule method calls another method that has _the same_ authorization constraint of the previous rule method. Boom! You just created an infinite loop. Run for your life! :) 247 | 248 | You may be thinking that you can control this but, trust me, it will get very difficult to maintain and scale. Things will not always be that simple, maybe you will end up calling a method that is later modified and ends up calling a helper function that triggers the same authorization loop. Yeah, I know. Indirection is a bitch :) Or, in other words "Great power comes with great responsibility". So beware of the infinite loop ;) 249 | 250 | 251 | h2(#decorators). Decorators 252 | 253 | If you like Python as much as I do, you will love decorators. Django has a permission_required decorator, so it felt natural that django-rules implemented an object_permission_required decorator. 254 | 255 | Imagine that for our "Example 1":#ex1 we have the following code in views.py: 256 | 257 |
258 | def ship_item(request, id):
259 |     item = Item.objects.get(pk = id)
260 |     
261 |     if request.user.has_perms('can_ship', item):
262 |         return HttpResponse('success')
263 | 
264 |     return HttpResponse('error')
265 | 
266 | 267 | We could easily decorate the view to make the method much more compact and easy to read: 268 | 269 |
270 | from django_rules import object_permission_required
271 | 
272 | @object_permission_required('can_ship')
273 | def ship_item(request, id):
274 |     return HttpResponse('Item successfully shipper! :)')
275 | 
276 | 277 | The magic of the decorator is very cool indeed. First, it matches the rule and gets the type of model from it. Then, it gets the id parameter from the view's kwargs and instantiates a Model object with item = model.objects.get(pk = id). Finally, it can call the request.user.has_perm('can_ship', item) for you and redirect to a fail page if the constraint is not fulfilled. 278 | 279 | Note how we have maintained the name of the model's primary key in the parameters of the view. If the parameter has a name that doesn't match the name of the primary key in the model, remember that we will have to add another optional parameter to the rule. From "the section on Rules:":#rules 280 | * view_param_pk _(optional)_: The view parameter's name to use for getting the primary key of the model. It is used in the decorated views for getting the actual instance of the model. If not set, it defaults to the name of the primary key field in the model. Note that if the name of the parameter of the view that holds the value of the object's primary key doesn't match the name of the primary key of the model, the new name must be specified in this parameter. 281 | 282 | For example, if we modify the parameter of the view: 283 | 284 |
285 | from django_rules import object_permission_required
286 | 
287 | @object_permission_required('can_ship')
288 | def ship_item(request, my_item_code):  #--> change in the naming of the parameter in the view
289 |     return HttpResponse('success')
290 | 
291 | 292 | We would have to specify the view_param_pk in the rule definition: 293 | 294 |
295 | rules_list = [
296 |     {'codename': 'can_ship', 'model': 'Item', 'view_param_pk': 'my_item_code',
297 |      'description': 'Checks if the given user is the supplier of the item'},
298 | ]
299 | 
300 | 301 | 302 | The object_permission_required decorator can receive 4 arguments: 303 | 304 |
305 | @object_permission_required('can_ship', return_403=True)
306 | @object_permission_required('can_ship', redirect_url='/more/foo/bar/')
307 | @object_permission_required('can_ship', redirect_field_name='myFooField')
308 | @object_permission_required('can_ship', login_url='/foo/bar/')
309 | 
310 | 311 | By default: 312 | * return_403 is set to False. 313 | * redirect_url is set to an empty string. 314 | * redirect_field_name is set to django.contrib.auth's REDIRECT_FIELD_NAME. 315 | * login_url is set to settings.LOGIN_URL. 316 | 317 | Thus, if the authorization constraint is not fulfilled, the decorator will default to a redirect to the login page in Django-style :) 318 | 319 | Also, note that a couple of the parameters have a specificity. Namely: 320 | * if return_403 is set to True it will override the rest of the parameters and the decorator will return a HttpResponseForbidden. 321 | * if redirect_url is set to a URL, it will override that of login_url. 322 | 323 | Finally, it is important to note a tricky detail regarding the use of the decorator to guard the access of those methods that are not directly exposed as views mapped to external URLs. When a view method is an entry point through URLs (that is, if your view method is mapped directly to one of the urls.py entries), Django parses the URL and passes the parameters to the view as kwargs. Thus, if you want to use the object_permission_required decorator over an internal method (a method that is called inside one of those external views or somewhere else in your code) you must use kwargs when passing the parameters. 324 | 325 | Let's see an example: 326 | 327 |
328 | def item_shipper(request, id):
329 |     internal_code = 'XXX-' + my_item_code
330 |     return _ship_item(request, id=internal_code)    # instead of doing return _ship_item(request, internal_code)
331 | 
332 | @object_permission_required('can_ship')
333 | def _ship_item(request, id)
334 |     return HttpResponse('success')
335 | 
336 | 337 | h2(#centralizedpermissions). Centralized Permissions 338 | 339 | django-rules has a central authorization dispatcher that is aimed towards a very common need in real life projects: the special, privileged groups such as administrators, user-support staff, etc., that have permissions to override certain aspects of the authorization constraints in the application. For such cases, django-rules has a way to let you bypass its authorization system for whatever reasons you have. 340 | 341 | To set up centralized permissions, you will need to set in your project settings the variable CENTRAL_AUTHORIZATIONS pointing to a module. Within that module, you will have to define a a boolean-returning function named central_authorizations accepting exactly two parameters: 342 | * user_obj: the user object. 343 | * codename: the codename of the rule we will be overriden. It is very useful to refine the permissions of a special user "a la ACL". 344 | Note that, although the naming of the parameters doesn't really matter, the order does. The first parameter will receive a user object, and the second parameter, the codename of the rule. 345 | 346 | This central_authorizations() function will be called *before* any other rule, so you can override all of them here. 347 | 348 | For example, in settings.py you will add: 349 | 350 |
351 | CENTRAL_AUTHORIZATIONS = 'myProjectFoo.utils'
352 | 
353 | 354 | And then, within myProjectFoo, in utils.py, you will implement the central_authorizations() function with the overrides for the special users. 355 | 356 | Imagine you want to give some special access to user support staff that will be able to access some private fields in the profile (for example, email and age) that generally are hidden to regular users of the application. They are user support, so they should not be able to override certain things in the application. Yet, you also want your über-admins (generally the developers) to be able to access anything within the application so that they can code and test quickly while developing. 357 | 358 | In such case, you can write the following central_authorizations() function: 359 | 360 |
361 | def central_authorizations(user_obj, codename):
362 |     """
363 |     This function will be called *before* any other rule,
364 |     so you can override all of the permissions here.
365 |     """
366 |     isAuthorized = False
367 | 
368 |     if user_obj.get_profile().isUberAdmin():
369 |         isAuthorized = True
370 |     elif user_obj.get_profile().isUserSupport() and codename in ['can_see_full_profile', 'can_delete_item']:
371 |         isAuthorized = True
372 | 
373 |     return isAuthorized
374 | 
375 | 376 | As you can imagine, everything that is checked in central_authorizations is global to the *whole* project. 377 | 378 | 379 | h2. Status and testing 380 | 381 | django-rules is meant to be a security application. Thus, it has been thoroughly tested. It comes with a battery of tests that tries to cover all of the available funcionality. However, if you come across a bug or an irregular situation, feel free to report it through the "Github bug tracker":https://github.com/maraujop/django-rules/issues. 382 | 383 | Finally, the application comes geared with many different exceptions that will make sure rules are created properly. They are also aimed to keep the security of your application away from negligence. Manage the exceptions wisely and you will be a happy and secure coder, as security is kept away from possible neglicence. You should manage them carefuly. 384 | 385 | 386 | h3. Testing django-rules 387 | 388 | To run tests, get into tests directory and execute: 389 | 390 |
391 | ./runtests.py
392 | 
393 | 394 | It should always say OK. If not, there is a broken test that I hope you will be reporting soon :) 395 | 396 | 397 | h2. Need more examples? 398 | 399 | I have done my best trying to explain the concept behind django-rules but, if you would rather look at more code examples, I am sure you will find the "code in the tests":https://github.com/maraujop/django-rules/blob/master/django_rules/tests/test_core.py quite useful :) 400 | 401 | 402 | h2. More Documentation 403 | 404 | In case you want to know where all this "per-object authentication backend in Django" came to exist, you should at least read the following links: 405 | * A great article about "per-object permission backends in Django":http://djangoadvent.com/1.2/object-permissions/ by Florian Apolloner 406 | * Also, check the explanation of the changes introduced when fixing the "django ticket #11010":http://code.djangoproject.com/ticket/11010 407 | 408 | Finally, my most sincere appreciation goes to everybody that contributes to the wonderful Django development framework and also to the rest of developers and committers that build django-rules with their help. Respect! :) 409 | --------------------------------------------------------------------------------