skip to content

Object-Level Checks

has_perm(user, perm, obj) hands an object to every backend, yet ModelBackend grants nothing per object. Interviewers ask how to add row-level rules and when queryset scoping beats per-object checks.

part ofDjangooverview, primer and where to startread it →
on this pageshow

explore

questions

4

In a Django property-listings app, how do you make sure owners can edit only their own listings?

level: middleimportance: must knowfreq 60%

answer

  1. the model permission is global
  2. narrow the lookup first
  3. 404 hides, 403 admits
  4. same rule on POST

basics

~20 s

Fetch the listing through a queryset limited to the user, such as get_object_or_404(Listing, pk=pk, owner=request.user), so another owner's listing returns 404. Apply the same scoping to list, update and delete views, and never rely on change_listing alone.

solid answer

~40 s

A model permission like `listings.change_listing` covers every listing, so ownership must be enforced per request. The usual idiom is to look the object up through a scoped queryset — `get_object_or_404(Listing, pk=pk, owner=request.user)`, ideally via a reusable `owned_by(user)` queryset method — so another owner's listing is simply not found and returns 404, and list pages use the same filter. When the listing is public but only its owner may edit, load it and raise `PermissionDenied` (403) if `listing.owner_id != request.user.pk`. A custom backend answering `has_perm(perm, listing)` centralises the rule. Apply it on POST as well as GET, and set the owner from `request.user` on create.

code

python · 15 lines
python
from django.contrib.auth.decorators import login_required
from django.shortcuts import get_object_or_404, redirect, render

from .forms import ListingForm
from .models import Listing


@login_required
def edit_listing(request, pk):
    listing = get_object_or_404(Listing.objects.owned_by(request.user), pk=pk)
    form = ListingForm(request.POST or None, instance=listing)
    if request.method == "POST" and form.is_valid():
        form.save()
        return redirect("listings:detail", pk=listing.pk)
    return render(request, "listings/edit.html", {"form": form})

go deeper

for a junior

Know the idiom: look the object up with owner=request.user so other people's objects are not found.

for a middle

Explain scoped lookup versus load-then-check, what 404 and 403 each communicate, and why the POST path needs the same guard.

for a senior

Centralise the rule in a queryset method or backend, cover every entry point, and handle staff overrides without scattering special cases.

for a principal

Set a codebase-wide convention for row ownership checks so new views and APIs cannot ship without one.

## The requirement In a property-listings site, any logged-in owner may create listings, and each owner may edit or delete **only their own**. The model: ```python from django.conf import settings from django.db import models class Listing(models.Model): owner = models.ForeignKey( settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="listings" ) title = models.CharField(max_length=200) monthly_rent = models.DecimalField(max_digits=9, decimal_places=2) ``` A model permission cannot express this: `listings.change_listing` means "may change listings", all of them. The ownership rule has to be applied per request. ## Idiom 1 — scope the lookup to the owner The most common Django answer is to fetch the object **through a queryset already limited to the user**: ```python from django.shortcuts import get_object_or_404 def edit_listing(request, pk): listing = get_object_or_404(Listing, pk=pk, owner=request.user) ... ``` `get_object_or_404()` accepts a model, a manager or a queryset and raises `Http404` when `get()` finds nothing. The view must require login first, since the filter needs a real user. Someone else's listing is indistinguishable from a missing one: - the rule is enforced by the query itself, so it cannot be skipped by a later branch; - the response is **404**, which does not reveal that listing 812 exists; - list pages use the same filter, `Listing.objects.filter(owner=request.user)`, so there is one rule for "which rows" and "which row". To stop the rule being re-typed in every view, put it on the model's queryset: ```python class ListingQuerySet(models.QuerySet): def owned_by(self, user): return self.filter(owner=user) class Listing(models.Model): ... objects = ListingQuerySet.as_manager() ``` Then `get_object_or_404(Listing.objects.owned_by(request.user), pk=pk)`. In a class-based view the same scoped queryset is returned from `get_queryset()`, so the detail, update and delete views inherit it. ## Idiom 2 — load, then check and refuse When the object should be visible but not editable — a public listing page with an owner-only edit form — load it normally and check ownership explicitly: ```python from django.core.exceptions import PermissionDenied listing = get_object_or_404(Listing, pk=pk) if listing.owner_id != request.user.pk: raise PermissionDenied ``` Raising `PermissionDenied` from a view produces a **403**. This is right when existence is public and you want to tell the user they lack rights; it is wrong when the existence of the object should stay hidden. ## Idiom 3 — ask the permission system With a custom authorization backend that answers object checks, the view says `request.user.has_perm("listings.change_listing", listing)`. That centralises the rule (and lets moderators pass through another rule) at the cost of writing and registering the backend. ## Comparison | Idiom | Response for another owner's listing | Works for list pages | Where the rule lives | |---|---|---|---| | Scoped lookup | 404 | Yes, same filter | Queryset method | | Load then check | 403 | No — per-row checks | Each view | | Object-aware backend | 403 (after explicit check) | No — per-row checks | One backend | ## Testing the rule Whichever idiom you pick, a small set of tests pins it down: - as owner A, `GET` and `POST` to owner B's edit URL both return 404 (or 403 for the load-then-check idiom); - as owner A, the list page contains only A's listings; - as owner A, a create request that submits `owner=B` in the body still produces a listing owned by A; - as a moderator, if moderators are allowed, the edit URL for B's listing returns 200. These tests fail the moment someone adds a new view that fetches `Listing.objects.get(pk=pk)` directly, which is exactly the regression the rule is meant to prevent. ## Mistakes interviewers listen for 1. **Checking only on GET.** The edit form's POST handler must apply the same scoping, or anyone can submit to `/listings/812/edit/`. 2. **Trusting a hidden `owner` field.** On create, set `listing.owner = request.user` in the view; never take the owner from submitted data. 3. **Relying on `change_listing` alone.** A permission decorator or mixin checks the model permission without the object, so it lets every owner edit every listing. 4. **Filtering lists but not detail views** — or the reverse. The same rule has to apply to every entry point, including delete and any API endpoint.

  • When is a 403 better than a 404 for someone else's listing?
    When the listing is public anyway — its page is visible to everyone — a 403 on the edit URL is honest and helps the user understand they lack rights. When the object's existence is itself sensitive, such as a draft listing, a 404 from a scoped lookup avoids confirming that it exists.
  • How do moderators edit any listing if the lookup is scoped to the owner?
    Make the scoping method aware of them: return the full queryset when `user.has_perm("listings.moderate_listing")` is true, otherwise filter by owner. The rule still lives in one queryset method, and every view that uses it picks up the moderator case.

saying these in an interview costs you the question

  • Granting change_listing restricts owners to their own listings
  • Hiding the Edit button is enough to protect the listing
  • The ownership check is only needed when the form is displayed
  • The owner can be taken from a hidden field in the submitted form
  • Scoped lookups leak existence because they return 403
open as a page

In Django, if a user holds 'listings.change_listing', may they edit every listing, and what does has_perm() return with obj=listing?

level: juniorimportance: should knowfreq 44%

basics

~10 s

Yes: model permissions are global, so change_listing covers every listing. has_perm('listings.change_listing', listing) returns False with only ModelBackend, because it grants nothing when an object is passed; only an active superuser passes.

open as a page

How would you write a Django authorization backend so has_perm('listings.change_listing', listing) is True only for that listing's owner?

level: seniorimportance: should knowfreq 38%

basics

~20 s

Subclass BaseBackend, override has_perm(user_obj, perm, obj) to return True when obj is a Listing whose owner_id equals the active user's pk, and add it to AUTHENTICATION_BACKENDS after ModelBackend. Callers must pass the listing to has_perm().

open as a page

What does Django's User.objects.with_perm() return, and why does it return no users when you pass obj=listing?

level: middleimportance: nice to knowfreq 18%

basics

~10 s

User.objects.with_perm('listings.approve_listing') returns a queryset of active users holding the permission directly or through a group, plus superusers by default. With obj set, ModelBackend returns an empty queryset because core has no object permissions.

open as a page