skip to content

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%

answer

  1. permissions are per model
  2. the obj argument is a hook
  3. default backend returns empty
  4. superusers still pass

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.

solid answer

~40 s

A Django model permission applies to the whole model, so `listings.change_listing` means "may change listings" with no notion of ownership, and any check made without an object passes for every listing. The API accepts an `obj` argument — `user.has_perm("listings.change_listing", listing)` — and passes it to every backend, but the default `ModelBackend` returns an empty set whenever `obj` is given, so the answer is `False`. It does not fall back to the model-level grant and never inspects the object. Only an active superuser passes, through the shortcut that skips backends. Per-object rules come from scoping queries to the user, a custom backend, or an object-permission package.

go deeper

for a junior

Remember that a model permission covers every row, and that adding obj to has_perm() returns False unless something extra answers it.

for a middle

Explain how the obj argument travels through PermissionsMixin to each backend and why ModelBackend returns an empty set for it.

for a senior

Choose between queryset scoping, a custom backend and a package, and make sure every caller actually passes the object.

for a principal

Decide where row-level authorization lives in the codebase so that every entry point — views, APIs, admin, jobs — applies the same rule.

## Model-level permissions are global A Django **model permission** such as `listings.change_listing` is a row in `auth_permission` tied to a *model*, not to a row of that model. The docstring on `Permission` states it plainly: permissions are set globally per type of object, so "Mary may change listings" is expressible, while "Mary may change listings, but only the ones she created" is not. So in a property-listings app, granting an owner `change_listing` so they can edit their flat also lets them pass every check that asks only "may this user change listings?" — which is every check written without an object. ## The `obj` argument exists, but core answers "no" The permission API was designed with objects in mind. `has_perm()`, `has_perms()`, `get_all_permissions()` and friends all accept an optional `obj`: ```python request.user.has_perm("listings.change_listing", listing) ``` `PermissionsMixin` passes the object on to every backend in `AUTHENTICATION_BACKENDS`. The default `ModelBackend`, however, returns an **empty set** whenever `obj is not None`, so its answer is `False`. The Django documentation calls this a foundation for object permissions with **no implementation in core**: object checks always return `False` or an empty collection until you add something that answers them. | Call (non-superuser holding `change_listing`) | Result with only `ModelBackend` | |---|---| | `user.has_perm("listings.change_listing")` | `True` | | `user.has_perm("listings.change_listing", listing)` | `False` | | `user.get_all_permissions(listing)` | `set()` | | Same object call for an active superuser | `True` — the superuser shortcut runs before any backend | Two things to notice: - `ModelBackend` does **not** fall back to the model-level answer when an object is given. The object form is a separate question, and core has no answer to it. - Nothing inspects the object. Passing `listing` does not make Django compare `listing.owner` with the user; there is no convention it could follow. ## What people expect instead A common misunderstanding is that `obj` is a filter on top of the model permission — "has the right *and* it applies to this listing". In reality the two calls are independent questions. If you switch a view from `has_perm(perm)` to `has_perm(perm, listing)` without adding a backend, every non-superuser is suddenly refused. ## How object-level rules are actually added There are three common routes, from simplest to most general: 1. **Scope the lookup to the user** — fetch the listing from `Listing.objects.filter(owner=request.user)`, so a listing that is not yours is simply not found. 2. **Write a custom authorization backend** whose `has_perm(user_obj, perm, obj)` returns `True` when `obj.owner_id == user_obj.pk`, and add it to `AUTHENTICATION_BACKENDS` next to `ModelBackend`. 3. **Use an object-permission package** that stores per-object grants or evaluates rule predicates, and plugs in as another backend. ## Callers must pass the object Even with a backend in place, an object rule only runs when the caller supplies the object. Django's own `permission_required` decorator and `PermissionRequiredMixin` check `has_perms()` **without** an object by default, and the admin's default `has_change_permission()` ignores the `obj` it receives. Object-level checks therefore have to be called explicitly, with the instance in hand, wherever the decision is made. ## Anonymous users reach backends too The object hook is not limited to logged-in users. `AnonymousUser.has_perm()` also walks the configured backends, passing the anonymous user object. `ModelBackend` returns an empty set for anonymous users, but a custom backend may answer — for example, allowing anyone to view listings whose `is_public` flag is set: ```python def has_perm(self, user_obj, perm, obj=None): if perm == "listings.view_listing" and obj is not None: return obj.is_public return False ``` Django's docs point out that this lets anonymous visitors hold permissions that inactive authenticated users do not, because inactive users are refused by `ModelBackend` while the anonymous object is handed to your rule unchanged. Whether that is desirable depends on the rule; it is one more reason custom backends should check `is_active` and `is_anonymous` explicitly. ## Summary for an interview - Model permissions are global per model. - `has_perm(perm, obj)` reaches every backend, but `ModelBackend` answers `False` for any object. - Active superusers still pass, because their shortcut ignores backends. - Row-level rules need scoping, a custom backend or a package — Django core provides only the hook.

  • Why does an active superuser pass has_perm(perm, listing) when ModelBackend refuses objects?
    `PermissionsMixin.has_perm()` returns `True` for an active superuser before any backend is consulted, with or without an object. The backend's refusal never runs, so superusers pass every object check — which also means testing object rules as a superuser proves nothing.
  • Does Django's permission_required decorator pass the object to has_perm()?
    No. The decorator and `PermissionRequiredMixin` call `has_perms()` with the permission names only, so an object-level backend is never consulted through them. You call `request.user.has_perm(perm, obj)` yourself once the object is loaded, or customise the check to supply it.

A model permission is like a master key for every flat in a building. Asking the default key cabinet about one specific flat gets a blank stare: it only knows about master keys, and someone has to add a cabinet that knows which flat belongs to whom.

saying these in an interview costs you the question

  • Passing obj makes Django compare the object's owner with the user
  • ModelBackend falls back to the model permission when obj is given
  • change_listing lets a user change only the listings they created
  • Object permissions work out of the box once contrib.auth is installed
  • A superuser fails object checks because ModelBackend returns an empty set