Django Object-level Permissions with DRF and Rules

Finding a simple way to limit API users’ permissions to certain objects.

We want an API via Django, so we use DRF. We want object-level permissions so we use django-rules. There are other options but Rules seemed more straightforward then django-guardian.

We want this for the manager of device 2:

>wget https://myapi.com/device/2
{ "id" : 2, "name": "sweety" }
>wget https://myapi.com/devices
["id" : 2]

We want this for the admin:

>wget https://myapi.com/device/2
{ "id" : 23, "name": "sweetpie" }
>wget https://myapi.com/devices
["id" : 2, "id" : 23, <all-devices> ]

We want this for the unauthorised users:

>wget https://myapi.com/device/2
403
>wget https://myapi.com/devices
403

We need to handle the following, which we’ll do below:

  • Authentication
  • Authorization to the table/model-level
  • Authorization to the row/object-level

Authentication

Install DRF and then setup. We use tokens (DRF Token docs are excellent) to authenticate. I think of a token as a username+password combined: it tells the app who I am and authenticates me. We make tokens in the app and give to users, there is no crypto magic.

# settings.py
INSTALLED_APPS += [
    "rest_framework",
    "rest_framework.authtoken",  
]

REST_FRAMEWORK = {
    "DEFAULT_PERMISSION_CLASSES": [
        "rest_framework.permissions.IsAuthenticated",
    ],

    "DEFAULT_AUTHENTICATION_CLASSES": (
        "rest_framework.authentication.TokenAuthentication",
        "rest_framework.authentication.SessionAuthentication",
    ),
}

We pass this in the request as a header Authorization HTTP header. For API access, in Python for example, this means adding a header to the requests.get() call.

In the code above, we also default to requiring Authentication in our API. The default is more open.

We’ll be using the TokenAuthentication, but SessionAuthentication is good to add so you can browse the api.

Authorization (obvs really with an -s-)

Once we know who the user is, should we let ’em access an object? In Django and DRF there is a fundamental distinction between ‘list’ and ‘detail’ views. Consider them as separate. Ignoring DRF for a minute, we want Django to only let a User of a Device (i.e. device1.users.all() includes that user) to read and write that device.

We use django-rules to do this. Docs are excellent too. We set it in the Model. Don’t be an idiot and use ‘read’ instead of ‘view’: get the names right!

# models.py
class Device(RulesModel):
    class Meta
        rules_permissions = {
            "add": rules.is_staff,
            "view": is_device_user | rules.is_staff,
            "delete": rules.is_staff,
            "change": is_device_owner | rules.is_staff,
        }

And then we implement the rules (example below). Note we can compose rules using OR, NOT, AND. Importantly, these rules can accept an obj (Device in this case). There are a few rules provided (e.g is_staff). It’s easy to write your own. This is the guts of the object-level permission:

# myrules.py
@rules.predicate
def is_device_user(user, device):
    if not device:
        return False
    return user in device.users.all()

Ok, so when Django does object-level checking, instead of the default “True for everything” it will run these rules. Great. Because it’s at the Model level, it will work for admin / custom-views / etc.

DRF has it’s own authorisation system

Oh yeah, right! But they play nice: just tell django-drf to use django‘s object-level interface. In our case that’s implemented by django-rules. What? Read that twice. In other words, we will reuse the existing object-level rules we put in models.py to determine the API’s object-level permissions. That’s DRY. Here’s the code:

# rest/views.py
class DeviceViewSet(viewsets.ModelViewSet):
    ...
    serializer_class = DeviceSerializer
    permission_classes = [
       StrictDjangoObjectPermissions
    ]
# rest/permissions

class StrictDjangoObjectPermissions(permissions.DjangoObjectPermissions):
    perms_map = {
        "GET": ["%(app_label)s.view_%(model_name)s"], # new
        "OPTIONS": [],  # ["%(app_label)s.view_%(model_name)s"], # dunno
        "HEAD": ["%(app_label)s.add_%(model_name)s"],
        "POST": ["%(app_label)s.add_%(model_name)s"],
        "PUT": ["%(app_label)s.change_%(model_name)s"],
        "PATCH": ["%(app_label)s.change_%(model_name)s"],
        "DELETE": ["%(app_label)s.delete_%(model_name)s"],
    }

The provided DjanoObjectPermission class is also perfect. I just make a change to GET: usually no permissions are required for the ‘safe’ GET option. I make the user need django ‘view’ permissions.

And then we just use the Django admin interface to setup users/group with the necessary permissions. You can also do this in code. I used a group to make it easy. You can automatically add users to this group via a CustomUser.save() or a post_save() signal if necessary.

image 34

So what happens when we do this

>wget --add-my-user-token https://myapi.com/device/2 
{ "id" : 2, "name": "sweety" } 

Well, the request is sent with a Token header (add-my-token is a fiction, but you get the idea). DRF authorises the User via rest_framework.authtoken and it adds .auth and .user to the request which the ViewSet receives.

The Viewset has permissions classes, which check the User and the Device via the is_device_user() predicate. It says ‘yes!’ and the view can provide a response. Or not, depending on the user/device.

Hang on, what about list views?

Confusingly, for performance reasons, list views don’t use these permissions automatically. Instead, we need to also filter them in the queryset:

class DeviceViewSet(viewsets.ModelViewSet):
    ...
    def get_queryset(self):
        if self.action == "list":
            if self.request.user.is_staff:
                return Device.objects.all()
            else:
                # Only return a list of user's devices, not .all()
                return Device.objects.filter(users__id=self.request.user.id)
        else:
            # object level view - handled by permission_classes
            return Device.objects.all()

And now this will work as expected:

>wget  --add-my-user-token https://myapi.com/devices
 ["id" : 2]
>wget  --add-my-admin-token https://myapi.com/devices
 ["id" : 2, "id":3, ... ]

Leave a Reply

Your email address will not be published. Required fields are marked *