Chris Chris - 6 months ago 39
Python Question

Can django-guardian and django-rules be used together?

I'd like to be able to create per-object permissions, using

django-guardian
.

But I'd like to add a layer of logic surrounding these permissions. For example if someone has
edit_book
permission on a
Book
, then their permission to edit
Pages
in that book should be implicit.
django-rules
seems ideal.

Answer

The following appears to work:

import rules
import guardian

@rules.predicate
def is_page_book_editor(user, page):
    return user.has_perm('books.edit_book', page.book)

@rules.predicate
def is_page_editor(user, page):
    return user.has_perm('pages.edit_page', page)

rules.add_perm('pages.can_edit_page', is_page_book_editor | is_page_editor)

Then to check:

joe.has_perm('pages.can_edit_page', page34)

Or:

@permission_required('pages.can_edit_page', fn=objectgetter(Page, 'page_id'))
def post_update(request, page_id):
    # ...

With the authentication backend defined:

AUTHENTICATION_BACKENDS = (
    'rules.permissions.ObjectPermissionBackend',
    'django.contrib.auth.backends.ModelBackend',
    'guardian.backends.ObjectPermissionBackend',
)

The imports:

from django.contrib.auth.models import User
import rules
import guardian
from guardian.shortcuts import assign_perm
from myapp.models import Book, Page

The tests:

joe = User.objects.create(username='joe', email='joe@example.com')
page23 = Page.objects.filter(id=123)
assign_perm('edit_page', joe, page23)
joe.has_perm('edit_page', page23)
is_page_editor(joe, page23)  # returns True
joe.has_perm('can_edit_page', i)  # returns True

rules.remove_perm('can_edit_page')
rules.add_perm('can_edit_page', is_page_book_editor & is_page_editor)
joe.has_perm('can_edit_page', i)  # returns False

A problem with this is that each time a rule is checked, each predicate makes a call to the database. The following adds caching so that there is only one query for each rule check:

@rules.predicate
def is_page_book_viewer(user, instance):
    if is_page_book_viewer.context.get('user_perms') is None:
        is_page_book_viewer.context['user_perms'] = guardian.shortcuts.get_perms(user, page.book)
    return 'view_book' in is_page_book_viewer.context.get('user_perms')

@rules.predicate(bind=True)
def is_page_viewer(self, user, instance):
    if self.context.get('user_perms') is None:
        self.context['user_perms'] = guardian.shortcuts.get_perms(user, instance)
    return 'view_page' in self.context.get('user_perms')

(I bind in the second example and use self, but this is identical to using the predicate name.)

As you're doing complex, composite permissions, it is probably wise to replace django-guardian's generic foreign keys with real ones that can be optimized and indexed by the database like so:

class PageUserObjectPermission(UserObjectPermissionBase):
    content_object = models.ForeignKey(Page)

class PageGroupObjectPermission(GroupObjectPermissionBase):
    content_object = models.ForeignKey(Page)

class BookUserObjectPermission(UserObjectPermissionBase):
    content_object = models.ForeignKey(Book)

class BookGroupObjectPermission(GroupObjectPermissionBase):
    content_object = models.ForeignKey(Book)

One final tweak - there is a bug. We're caching permissions on Page and Book in the same place - we need to distinguish and cache these separately. Also, let's encapsulate the repeated code into its own method:

def cache_permissions(predicate, user, instance):
    """
    Cache all permissions this user has on this instance, for potential reuse by other predicates in this rule check.
    """
    key = 'user_%s_perms_%s_%s' % (user.pk, type(instance).__name__, instance.pk)
    if predicate.context.get(key) is None:
        predicate.context[key] = guardian.shortcuts.get_perms(user, instance)
    return predicate.context[key]

This way object permissions will be cached separately. (Including user id in key is unnecessary as any rule will only check one user, but is a little more future-proof.)

Then we can define our predicates as follows:

@rules.predicate(bind=True)
def is_page_book_viewer(self, user, instance: Page):
    return 'view_book' in cache_permissions(self, user, instance.book)