Dev.to Security πŸ” Cybersecurity πŸ‘ 0 πŸ“– 4 min read

Row-level permissions in Django, without a row per person

Django's permission system answers one question well: may this person change orders? Real projects ask three more, and Django leaves each of them to you. Which orders? A branch manager edits their branch's orders, not

Django's permission system answers one question well: may this person change
orders?
Real projects ask three more, and Django leaves each of them to you.

  • Which orders? A branch manager edits their branch's orders, not every branch's. A customer reads their own.
  • Which fields? A manager reads an order, but not what the shop paid.
  • Which values? Anybody who may edit an order may fix its item - but only whoever may approve sets it to approved.

The usual answer is a filter in the view:

def get_queryset(self):
    return Order.objects.filter(branch=self.request.user.branch)

It works, until it has to be written again - in the list view, the detail view,
the API, the admin class, the CSV export, the report. The one that is forgotten
does not fail. It shows every branch's orders to everybody, and nobody notices,
because nothing is wrong on the screen.

Two existing answers

django-guardian gives a permission on one object: "Ana may edit order
1042". It stores a row per person per object. That is right for documents
shared one by one, and costly for a rule like "managers edit their branch's
orders": every new order needs rows for every manager of its branch, and a
manager who moves branch needs all of them rewritten.

django-rules decides in code, with predicates. It is light and clear, and
every rule is a deployment - an administrator cannot change who sees what.

Scopes

django-clearance takes a
third way. A permission is given with a scope - a rule about rows - and the
scope becomes one filter on the query.

A model offers scopes in code:

from clearance.querysets import ScopedQuerySet


class OrderQuerySet(ScopedQuerySet):
    scopes = {
        'own': _('Their own orders'),
        'branch': _("Their branch's orders"),
    }

    def scope_own(self, user):
        return Q(customer=user)

    def scope_branch(self, user):
        return Q(branch=getattr(user, 'branch_id', None))


class Order(models.Model):
    ...
    objects = OrderQuerySet.as_manager()

An administrator gives permissions in bundles - Django's groups - with a
scope beside each one: Branch managers hold view and edit on orders, in
Their branch's orders. And every list asks:

Order.objects.visible_to(user, 'edit')   # the orders this person may edit

Nothing is stored per row. A new order is reached by whoever's scope matches
it; a manager who moves branch reaches the new branch's orders on the next
request.

Rules, with no code

Scopes in code are written by developers. An administrator builds more, in the
admin site, from conditions:

status      is one of   pending, approved
branch      is          the person's branch

A rule is only ever a Django Q, checked when it is saved: every field must
exist, every operator suit its field, and no condition may read a protected
field
- a filter on the price would tell whoever holds the rule what the
price is.

Fields and values

The price is read with one permission and written with another:

class Meta:
    default_permissions = ()
    permissions = build_permissions(
        'order',
        _('orders'),
        actions=(*DEFAULT_ACTIONS, 'approve'),
        protected_fields=('price',),
        status_actions={
            'approve': {'field': 'status', 'to': ['approved', 'rejected'], 'from': ['pending']},
        },
    )

approve is a status action: only whoever holds it writes approved or
rejected, and only from pending. Whoever may edit the order still writes
the other values. Forms and serializers offer only the values the person may
set, and the API refuses the others with a 400 naming the field.

Every door asks the same rules

The same rules are asked wherever a record is read or written:

class OrderViewSet(ScopedModelViewSet):
    queryset = Order.objects.all()
    serializer_class = OrderSerializer   # written knowing nothing of access

The viewset narrows the rows, asks the model's policy for each action, leaves
out the price for whoever may not read it, and refuses a value the person may
not write. Django's class-based views use three mixins; the admin site needs
nothing at all - every ModelAdmin gets the rules from the site.

A rule no permission can say - "nobody edits an order once it is delivered" -
is a policy, written once:

class OrderPolicy(BasePolicy):
    model = Order

    def can_edit(self, user, obj=None):
        if not self.has_within_scope(user, 'edit', obj):
            return False
        return obj is None or obj.status != Order.Status.DELIVERED

Denied by default

Access control fails in two directions, and only one of them is noticed.
Refusing too much is reported the same day. Showing too much is reported by
nobody. So every gap the library can leave refuses:

  • a permission given with no scope reaches no rows;
  • a rule whose field is gone reaches no rows, not every row;
  • a field marked protected is left out until a permission gives it;
  • a view that cannot say which policy to ask is refused at start-up.

And whoever manages access cannot give themselves more: nobody changes their
own access, gives a permission they do not hold, or leaves the platform with
nobody able to manage it.

What it costs

A person's grants are read once per request, in one query, and a model's rules
in one more. Every list after that is its own query - the scopes are a WHERE
clause. The library's tests count the queries of every door, and ask again with
more rows to hold that the number does not grow.

Trying it

pip install 'django-clearance[drf]'

The tutorial builds
an example shop step by step, and the
admin panel section
shows every page. The documentation is in English and Persian, and so is the
admin site.

It is alpha, and feedback on the API is exactly what it needs now:
github.com/amirAI-dot/django-clearance.

πŸ“° Read the original article on Dev.to Security

Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes β€” full credit and traffic to the original publisher.