Implement Row-Level Permissions
This guide adds Row-Level Permissions to a model, so each user sees and changes only the rows that your rules allow. It ends with the tests that confirm the rules. Row-Level Permission Filtering describes where each hook runs and why the two scopes are separate.
Before You Begin
Check that the model's API uses the VUEDA defaults that run the hooks:
- The model's viewset inherits from VuedaViewSet. Its
listcomes from ListRowLevelViewSetMixin, which applies row filtering. - The API's permission class is ObjectPermissions. VUEDA's default settings set it in
DEFAULT_PERMISSION_CLASSES. Check that your settings and the viewset keep it. - The user model (Django: AUTH_USER_MODEL) includes VUEDAPermissionsMixin, whose
has_permruns the instance hooks. AbstractVUEDAUser includes it.
The examples below use this model:
from django.conf import settings
from django.db import models
class Project(models.Model):
owner = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
is_public = models.BooleanField(default=False)The rule: a user with the model permission can read any public project. Only the owner can read a private project, and only the owner can change or delete a project.
Step 1: Filter Rows with check_queryset
Add a RowLevelPermissions inner class that inherits from BaseRowLevelPermissions, and implement BaseRowLevelPermissions.check_queryset:
from django.db.models import Q
from vueda.core.permissions import BaseRowLevelPermissions
class Project(models.Model):
...
class RowLevelPermissions(BaseRowLevelPermissions):
@classmethod
def check_queryset(cls, queryset, perm, user, perm_type):
if perm_type == "delete":
return Q(owner=user)
return Q(is_public=True) | Q(owner=user)The hook receives the queryset, the full permission codename in perm, and the user. Its perm_type is "list" for list responses and "delete" for bulk delete. It is "read" when Model History filters events about the model's rows.
Return a Django: Q to filter the rows, False for no rows, or True or None to leave them unfiltered. The filter runs in the database, so express the rule over model fields.
Step 2: Check Single Objects with check_instance
check_queryset does not affect retrieve, update, or delete of a single object. Without an instance hook, a user can open, by pk, a row that list hides. Add BaseRowLevelPermissions.check_instance with the same rule:
class RowLevelPermissions(BaseRowLevelPermissions):
# check_queryset from step 1
@classmethod
def check_instance(cls, model, obj, perm, user, perm_type):
if obj.owner_id == user.pk:
return None # keep the model permission decision
if perm_type == "read" and obj.is_public:
return None
return FalseThe hook runs on every object permission check. Its perm_type is the codename's action prefix, such as read, update, or delete. Return False to deny, or None to keep the decision of the earlier layers. A True return replaces that decision with a grant.
Permission Model describes the layer order. Permissions lists each hook's arguments and return values.
Keep the hook cheap. When a response includes Available Actions, the server runs an object check for each built-in action on each row. check_instance then runs several times per row of a list page.
Step 3: Add the Workflow Hooks (Workflow Models Only)
For a Workflow-Enabled Model, State Permission rules already filter the rows and take part in object checks. Add the Row-Level Workflow Permissions hooks only when your row rule must combine with the object's state. This example requires a non-owner to hold a state grant for the row's current state. The owner needs no grant, and a state deny applies to everyone:
class RowLevelPermissions(BaseRowLevelPermissions):
# check_queryset and check_instance from steps 1 and 2
@classmethod
def check_queryset_workflow(
cls, queryset, perm, user, perm_type, state_denied_annotation, state_granted_annotation
):
# The state rules already removed denied rows. Non-owners also need a state grant.
return Q(owner=user) | Q(**{state_granted_annotation: True})
@classmethod
def check_instance_workflow(cls, model, obj, perm, user, perm_type, grant_or_deny):
if obj.owner_id != user.pk and grant_or_deny is not True:
return False # a non-owner needs a state grant
return None # keep the earlier decision, a state deny includedBaseRowLevelPermissions.check_queryset_workflow runs after the state rules remove denied rows. It can narrow the rows further but cannot restore rows that the state rules removed. It receives the names of two boolean annotations on the queryset, state_denied_annotation and state_granted_annotation. Use them in the Q you return.
BaseRowLevelPermissions.check_instance_workflow runs last in an object check. It receives the state rules' result in grant_or_deny (True, False, or None). A non-None return is the final decision, even over a state deny. A True that overrides a state deny has no list counterpart, because check_queryset_workflow cannot restore the row. Such a row opens by pk but stays out of list. Workflow as a Permission Overlay describes how state rules decide.
Step 4: Keep Row Filtering in Custom Code
The default list calls ListRowLevelViewSetMixin.apply_row_level_filter after the filter backends and before pagination and Column Totals. Row filtering does not happen in get_queryset. A custom action, or an overridden list that does not call the mixin's list, returns unfiltered rows. Call apply_row_level_filter yourself at the same point:
queryset = self.filter_queryset(self.get_queryset())
queryset = self.apply_row_level_filter(queryset)
page = self.paginate_queryset(queryset)A custom detail action that loads its object with self.get_object() runs the object permission check, which calls check_instance. An action that queries the model directly skips it.
Step 5: Test Allowed and Denied Users
Give the test users their model permissions through groups, for example myapp.list_project, myapp.read_project, myapp.update_project, and myapp.delete_project. Then vary only the row conditions: owner, non-owner of a public project, and non-owner of a private project. Permissions lists the status code for each refusal.
Cover these cases:
list: an owner sees their own rows plus public rows. A user who matches no rows gets200with emptyresultsandtotalRecordsof0.listwith pagination and totals:totalRecords,totalPages, andcolumnTotalscount only the visible rows. If the viewset declares column totals, test them with row filtering active.- Retrieve: the owner gets
200. A non-owner of a private project gets404, which hides that the row exists. - Update and single delete: a non-owner of a public project gets
403, because they can read it. A non-owner of a private project gets404. - Bulk delete: when every requested pk passes both hooks, the rows are deleted. When any pk fails, nothing is deleted. The response is
400, keyed by pk, with"Object with pk=... does not exist.", the same message as for a missing pk.
Row-Level Permission Filtering explains the empty list, the 404, and the bulk delete message. Bulk delete runs the object check once for each row that passes check_queryset, so its time grows with the number of pks.
Troubleshooting
list returns every row. The viewset does not inherit from VuedaViewSet, or it overrides list without calling apply_row_level_filter (step 4).
Retrieve returns 200 for a row that should be denied. check_instance returns None for that row, which keeps the model permission decision. Return False to deny.
A row is in list but retrieve returns 404, or the reverse. check_queryset and check_instance disagree for that row. VUEDA does not compare the two hooks, so keep their rules consistent.