skip to content

A DRF ModelViewSet guards documents with an owner-only has_object_permission, yet its list endpoint returns every user's documents — why, and how do you close the gap?

level: seniorimportance: should knowfreq 48%

answer

  1. which actions call get_object()
  2. permissions are not a row filter
  3. scope get_queryset by request.user
  4. create needs its own owner rule

basics

~10 s

DRF applies object permissions only through get_object(), which list and create never call. Scope get_queryset() to request.user so lists and lookups see only owned rows, and set the owner server-side in perform_create().

solid answer

~40 s

`has_object_permission()` runs only when `check_object_permissions()` is called, and generic views call it only inside `get_object()` — so `retrieve`, `update`, `partial_update` and `destroy` are protected, while `list` serialises the whole queryset and `create` has no object to check. The DRF docs state it directly: object permissions are not applied to each instance of a list. The fix is to make the queryset the access boundary: override `get_queryset()` to return `Document.objects.filter(owner=self.request.user)`. Lists then show only owned documents, and a detail request for someone else's document gets 404 from `get_object_or_404()`, which also hides that it exists. For `create`, set `owner` in `perform_create()` and keep it read-only in the serializer. Keep the object permission as defence in depth for actions whose rules differ from visibility.

code

python · 20 lines
python
from rest_framework import permissions, viewsets

from .models import Document
from .serializers import DocumentSerializer


class IsOwner(permissions.BasePermission):
    def has_object_permission(self, request, view, obj):
        return obj.owner_id == request.user.pk


class DocumentViewSet(viewsets.ModelViewSet):
    serializer_class = DocumentSerializer
    permission_classes = [permissions.IsAuthenticated, IsOwner]

    def get_queryset(self):
        return Document.objects.filter(owner=self.request.user)

    def perform_create(self, serializer):
        serializer.save(owner=self.request.user)

go deeper

for a junior

Know that DRF permissions do not filter list results and that a per-user get_queryset() is the usual way to show users only their own records.

for a middle

Explain which ModelViewSet actions call get_object(), why list and create skip the object hook, and why filtered lookups answer 404.

for a senior

Audit a viewset end to end — list, create, custom actions, overridden get_object() — and combine queryset scoping with object checks where visibility and edit rights differ.

for a principal

Set a team convention that access boundaries live in get_queryset() with object permissions as a second layer, and back it with tests that request other users' objects.

## Why the list leaks In Django REST Framework (DRF), a permission class has two hooks. `has_permission()` runs for every request; `has_object_permission()` runs only when the view calls `check_object_permissions(request, obj)`. In the generic views that call lives in exactly one place: `GenericAPIView.get_object()`, after it filters the queryset and fetches one instance with `get_object_or_404()`. Now trace a `ModelViewSet`: | Action | Calls `get_object()`? | Owner check applied? | |---|---|---| | `list` | no — paginates and serialises the queryset | **no** | | `create` | no — the object does not exist yet | **no** | | `retrieve` | yes | yes | | `update` / `partial_update` | yes | yes | | `destroy` | yes | yes | So with `queryset = Document.objects.all()` and an `IsOwner` class that only implements `has_object_permission()`, every authenticated user can page through every document. The DRF permissions guide says this explicitly: for performance reasons the generic views do not apply object-level permissions to each instance when returning a list. A permission class is a **gate**, not a **row filter**. ## Fix 1: make the queryset the boundary Override `get_queryset()` so the view can only ever see the caller's rows: ```python def get_queryset(self): return Document.objects.filter(owner=self.request.user) ``` This one change repairs several paths at once: - `list` returns only the caller's documents, including correct pagination counts. - `retrieve`, `update` and `destroy` on another user's document now fail inside `get_object_or_404()` with **404**, before any permission hook runs — which also avoids confirming that the ID exists. - Custom detail actions that call `get_object()` inherit the same scoping. Two practical details follow. A viewset without a `queryset` class attribute cannot give the router a default name, so register it with an explicit `basename`. And `get_queryset()` runs after authentication, so `self.request.user` is resolved; with `IsAuthenticated` in front of it, anonymous users are rejected before the filter would ever see an `AnonymousUser`. ## Fix 2: close the create path `has_object_permission()` never sees a `POST`, so an owner-only class cannot stop a user from submitting `owner` as someone else's ID. The owner must come from the server: 1. mark `owner` read-only in the serializer (or leave it out of the writable fields); 2. override `perform_create()` to call `serializer.save(owner=self.request.user)`; 3. if creating is itself restricted, express that in `has_permission()` (for example by branching on `view.action == "create"`) or in the serializer's validation — not in the object hook. ## Other paths that skip the object hook - A detail `@action` that queries `Document.objects.get(...)` directly instead of calling `self.get_object()`. - An overridden `get_object()` that forgets `check_object_permissions()`. - Bulk endpoints that update or delete a filtered queryset in one statement. - An `APIView` or `@api_view` function that loads objects by hand. Each of these is safe only if it either uses the scoped `get_queryset()` or calls the check explicitly. ## Scoping versus object permissions | Concern | Scoped `get_queryset()` | `has_object_permission()` | |---|---|---| | Hides rows from `list` | yes | no | | Response for another user's object | 404 | 403 | | Needs the object loaded | no | yes | | Good for "can see but not edit" | no, alone | yes | The two combine well. When visibility and editability differ — shared documents anyone in a team may read but only the owner may edit — `get_queryset()` expresses **what the caller may see**, and `has_object_permission()` expresses **what the caller may do** to a visible object.

  • In DRF, why might you still keep IsOwner once get_queryset() filters by owner?
    As defence in depth and for rules that differ from visibility. If `get_queryset()` is later widened — say, to include documents shared with the user's team — the object hook still stops non-owners editing, turning a would-be data-integrity bug into a 403.
  • What goes wrong if a DRF detail @action fetches the document with Document.objects.get(pk=pk)?
    It bypasses both protections: the unscoped manager ignores `get_queryset()`, and nothing calls `check_object_permissions()`. Any authenticated user can act on any document by ID. The action should call `self.get_object()`, which uses the scoped queryset and runs the object hook.

saying these in an interview costs you the question

  • DRF applies has_object_permission to each row before serialising a list.
  • An owner-only object permission also stops users creating documents for others.
  • Filtering get_queryset makes foreign documents return 403 instead of 404.
  • Pagination hides the leak, so an unscoped list is safe enough.
  • A detail action is protected even when it queries the model manager directly.