---
url: "https://vueda.dev/v3/guides/implement-row-level-permissions.md"
title: "Implement Row-Level Permissions"
---

# Implement Row-Level Permissions

This guide adds [Row-Level Permissions](/v3/reference/glossary.md#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](/v3/core-concepts/row-level-permission-filtering.md) 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](/v3/reference/api/py/vueda.core.viewsets/VuedaViewSet.md). Its `list` comes from [ListRowLevelViewSetMixin](/v3/reference/api/py/vueda.core.viewsets/ListRowLevelViewSetMixin.md), which applies row filtering.
- The API's permission class is [ObjectPermissions](/v3/reference/api/py/vueda.core.permissions/ObjectPermissions.md). 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](https://docs.djangoproject.com/en/6.1/ref/settings/#std-setting-AUTH_USER_MODEL)) includes [VUEDAPermissionsMixin](/v3/reference/api/py/vueda.user.mixins/VUEDAPermissionsMixin.md), whose [`has_perm`](/v3/reference/api/py/vueda.user.mixins/VUEDAPermissionsMixin.md#has_perm) runs the instance hooks. [AbstractVUEDAUser](/v3/reference/api/py/vueda.user.models/AbstractVUEDAUser.md) includes it.

The examples below use this model:

```python
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](/v3/reference/api/py/vueda.core.permissions/BaseRowLevelPermissions.md), and implement [BaseRowLevelPermissions.check\_queryset](/v3/reference/api/py/vueda.core.permissions/BaseRowLevelPermissions.md#check_queryset):

```python
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`](/v3/reference/api/py/vueda.core.permissions/BaseRowLevelPermissions.md#check_queryset-param-perm_type) is `"list"` for `list` responses and `"delete"` for bulk delete. It is `"read"` when [Model History](/v3/reference/glossary.md#model-history) filters events about the model's rows.

Return a [Django: Q](https://docs.djangoproject.com/en/6.1/ref/models/querysets/#django.db.models.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](/v3/reference/api/py/vueda.core.permissions/BaseRowLevelPermissions.md#check_instance) with the same rule:

```python
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 False
```

The hook runs on every object permission check. Its [`perm_type`](/v3/reference/api/py/vueda.core.permissions/BaseRowLevelPermissions.md#check_instance-param-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](/v3/core-concepts/permission-model.md) describes the layer order. [Permissions](/v3/reference/permissions.md) lists each hook's arguments and return values.

Keep the hook cheap. When a response includes [Available Actions](/v3/reference/glossary.md#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](/v3/reference/glossary.md#workflow-enabled-model), [State Permission](/v3/reference/glossary.md#state-permission) rules already filter the rows and take part in object checks. Add the [Row-Level Workflow Permissions](/v3/reference/glossary.md#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:

```python
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 included
```

[BaseRowLevelPermissions.check\_queryset\_workflow](/v3/reference/api/py/vueda.core.permissions/BaseRowLevelPermissions.md#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`](/v3/reference/api/py/vueda.core.permissions/BaseRowLevelPermissions.md#check_queryset_workflow-param-state_denied_annotation) and [`state_granted_annotation`](/v3/reference/api/py/vueda.core.permissions/BaseRowLevelPermissions.md#check_queryset_workflow-param-state_granted_annotation). Use them in the `Q` you return.

[BaseRowLevelPermissions.check\_instance\_workflow](/v3/reference/api/py/vueda.core.permissions/BaseRowLevelPermissions.md#check_instance_workflow) runs last in an object check. It receives the state rules' result in [`grant_or_deny`](/v3/reference/api/py/vueda.core.permissions/BaseRowLevelPermissions.md#check_instance_workflow-param-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](/v3/core-concepts/workflow-permission-overlay.md) describes how state rules decide.

## Step 4: Keep Row Filtering in Custom Code

The default `list` calls [ListRowLevelViewSetMixin.apply\_row\_level\_filter](/v3/reference/api/py/vueda.core.viewsets/ListRowLevelViewSetMixin.md#apply_row_level_filter) after the filter backends and before pagination and [Column Totals](/v3/reference/glossary.md#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:

```python
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](/v3/reference/permissions.md) 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 gets `200` with empty `results` and `totalRecords` of `0`.
- `list` with pagination and totals: `totalRecords`, `totalPages`, and `columnTotals` count 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 gets `404`, 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 gets `404`.
- 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](/v3/core-concepts/row-level-permission-filtering.md) 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.
