Permissions & Authorization

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against Django 6.1 (6.1.1, released 2026-09-02; 6.1.2 is scheduled for 2026-10-06), Python 3.12-3.14 and PostgreSQL 15 or newer, all checked on 2026-10-03; where the 5.2 LTS (5.2.17) behaves differently the text says so. Every example was run on that stack in a scratch project on SQLite with the Django test client, the manage.py check command and a test suite of 25 tests (the Django REST framework example used 3.18.1, the object-level examples django-guardian 3.5.0, released 2026-09-12, and django-rules 3.5, released 2024-09-02; both classifiers stop below Django 6.1, yet both ran without problems on 6.1.1). Authentication answers "who is this?"; authorization answers "what may this person do?". Django ships a small, solid answer for the second question at the level of a model (may this user add, change, delete or view books at all?), a thin hook for the level of a single object (may this user change this book?), and nothing at all for the level of a row set (which books may this user even see?). This page covers the three levels for the Bookshelf application (the catalog app built across this section), and the tests that prove the rules hold. Signing in is covered on Authentication, the user model on Custom User Model & Backends, and how the admin applies the same permissions on The Admin Site.

In a hurry: which tool for which question

Question Tool Section

May this user do this kind of thing to this kind of record?

A model permission (app_label.codename) granted directly or through a Group, checked with user.has_perm().

Model permissions

Which permissions does a role bundle?

A Group with a set of Permission rows; add users to the group.

Groups

Protect a view

@permission_required or PermissionRequiredMixin for permissions, @user_passes_test or UserPassesTestMixin for anything else.

Enforcing permissions in views

May this user change this object?

An ownership test in test_func(), or an object-aware backend: django-guardian (permissions stored per object) or django-rules (permissions computed by predicates).

Object-level permissions

Which objects may this user see at all?

A scoped queryset (Book.objects.visible_to(user)) used by every view, API and admin that lists or fetches them.

Queryset scoping

Prove it works

Tests that assert both the denial and that the database did not change, with a positive control.

Testing authorization

Model permissions

When django.contrib.auth is installed, migrate creates four permissions for every model of every installed app: add_<model>, change_<model>, delete_<model> and view_<model>. The view permission was added in Django 2.1; older books only know the first three. A permission is addressed by the string "<app label>.<codename>".

For a Bookshelf Book, the permissions the application needs beyond the four defaults are declared in Meta.

A model with a custom permission (catalog/models.py)
from django.conf import settings
from django.db import models
from django.db.models import Q


class BookQuerySet(models.QuerySet):
    def visible_to(self, user):
        """Rows the user may see: published books, plus their own drafts."""
        if user.has_perm("catalog.view_book"):
            return self
        if user.is_authenticated:
            return self.filter(Q(published=True) | Q(owner=user))
        return self.filter(published=True)


class Book(models.Model):
    title = models.CharField(max_length=200)
    owner = models.ForeignKey(
        settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="books"
    )
    published = models.BooleanField(default=False)

    objects = BookQuerySet.as_manager()

    class Meta:
        permissions = [
            ("publish_book", "Can publish a book"),
        ]

makemigrations records the option and migrate creates the rows, in a post_migrate handler. The permission is only a row: Django never enforces publish_book by itself. The code that publishes a book must ask for it, as the views below do. Listing what exists for a model is a query on Permission:

The five permissions of Book, from the test suite
codenames = set(
    Permission.objects.filter(content_type__app_label="catalog", content_type__model="book")
    .values_list("codename", flat=True)
)
assert codenames == {"add_book", "change_book", "delete_book", "view_book", "publish_book"}

Permissions can also be created in code, which is useful for permissions that do not belong to a model and for tests. Each row belongs to a content type, because the pair (content type, codename) is what makes a codename unique:

Creating a permission programmatically
from django.contrib.auth.models import Permission
from django.contrib.contenttypes.models import ContentType

ct = ContentType.objects.get_for_model(Book)
perm = Permission.objects.create(
    codename="feature_book", name="Can feature books", content_type=ct
)
user.user_permissions.add(perm)

Two details are easy to trip over. Proxy models get their own permissions and do not inherit those of the concrete model, and to look up their content type you must pass for_concrete_model=False to get_for_model(). And two changes arrived in Django 6.1: when a model is renamed, migrate now renames its default permissions to match (and raises an error with next steps if the new codename already exists, for example from a leftover permission), where older versions left the old rows in place and created fresh, ungranted ones; and the new Permission.user_perm_str property returns the string to hand to has_perm(), so code no longer builds f"{app_label}.{codename}" by hand.

user_perm_str (Django 6.1)
perm = Permission.objects.get(codename="publish_book")
assert perm.user_perm_str == "catalog.publish_book"

Checking permissions with has_perm

user.has_perm("catalog.add_book") is the single question the rest of the page builds on; has_perms([…​]) requires all of the listed permissions. Behind the scenes has_perm() asks every backend in AUTHENTICATION_BACKENDS, and the user gets a permission if any one backend grants it (a backend that raises PermissionDenied ends the check at once). The rules for the built-in ModelBackend are worth memorising:

  • an active superuser has every permission, including ones that do not exist, without any row granting it

  • an inactive user has none, even as a superuser

  • an anonymous user has none (AnonymousUser.has_perm() is always False with ModelBackend; a custom backend may answer differently, which is how django-guardian and django-rules support anonymous access)

  • a regular user has the permissions assigned to them directly (user_permissions) plus those of all their groups

  • with an obj argument, ModelBackend returns False: Django has the plumbing for object permissions and no implementation of them

flowchart TD A["user.has_perm(perm, obj)"] --> B{"active superuser?"} B -- yes --> Y["True"] B -- no --> C{"active?"} C -- no --> N["False"] C -- yes --> D["ask each backend in AUTHENTICATION_BACKENDS"] D --> E{"any backend raises PermissionDenied?"} E -- yes --> N E -- no --> F{"any backend returns True?"} F -- yes --> Y F -- no --> N
Figure 1. The has_perm decision, simplified from the documentation
The rules above, as assertions
def test_has_perm_group_and_superuser_and_inactive(self):
    self.assertFalse(self.ann.has_perm("catalog.add_book"))
    self.assertTrue(self.ed.has_perm("catalog.add_book"))            # via the Editors group
    self.assertFalse(self.ed.has_perm("catalog.delete_book"))
    self.assertTrue(self.ed.has_perms(["catalog.add_book", "catalog.publish_book"]))
    self.assertFalse(self.ed.has_perms(["catalog.add_book", "catalog.delete_book"]))
    root = User.objects.create_superuser("root", password="not-a-real-password")
    self.assertTrue(root.has_perm("catalog.delete_book"))
    self.assertTrue(root.has_perm("no.such_permission"))             # superusers pass everything
    root.is_active = False
    root.save()
    root = User.objects.get(pk=root.pk)
    self.assertFalse(root.has_perm("catalog.delete_book"))
    self.assertFalse(AnonymousUser().has_perm("catalog.view_book"))

The "superuser passes everything" rule has a consequence for tests: a typo in a permission string is invisible when the test user is a superuser. Test with ordinary users.

The permission cache

ModelBackend caches a user’s permissions on the user instance after the first check. In a view that is harmless because the instance lives for one request, but code that grants a permission and checks it straight away sees the old answer, and so does a test:

A stale permission cache
user = User.objects.create_user("cache", password="not-a-real-password")
assert not user.has_perm("catalog.view_book")
user.user_permissions.add(Permission.objects.get(codename="view_book"))
assert not user.has_perm("catalog.view_book")   # stale: answered from the cache
user.refresh_from_db()
assert not user.has_perm("catalog.view_book")   # refresh_from_db() does not clear it
user = User.objects.get(pk=user.pk)             # a fresh instance does
assert user.has_perm("catalog.view_book")

Async code

The user and the backends have async twins (ahas_perm(), ahas_perms(), aget_all_permissions() and friends, added in Django 5.2), so an async view does not need sync_to_async:

Checking a permission in async code (Django 5.2+)
async def can_add(user):
    return await user.ahas_perm("catalog.add_book")

The class-based mixins and decorators below are synchronous. They work on a request.user already loaded by AuthenticationMiddleware, and a custom async view calls request.auser() (see Authentication) and ahas_perm() itself.

In templates

The auth context processor puts a perms object in every template context. perms.catalog.add_book is the template spelling of has_perm("catalog.add_book"), and perms.catalog of has_module_perms("catalog") (any permission of the app):

Showing UI by permission (catalog/templates/catalog/list.html)
{% if perms.catalog.add_book %}<a href="/books/add/">Add a book</a>{% endif %}
{% if perms.catalog %}<p>has some catalog permission</p>{% endif %}

Hiding a link is a courtesy, not a control: the view behind it must enforce the same rule.

Groups

A Group is a named bag of permissions. A user in a group has all of its permissions, and the groups of a user are a many-to-many (user.groups.add(group)). Roles in the Bookshelf (editors who add, change and publish books) are groups; assigning permissions one user at a time does not scale and is hard to audit.

Create groups from code that is safe to re-run. A management command run after migrate is the simplest form, and set() makes repeated runs converge on the declared list instead of accumulating permissions:

Declaring the Editors group (catalog/management/commands/setup_groups.py)
from django.contrib.auth.models import Group, Permission
from django.core.management.base import BaseCommand

EDITORS = ["add_book", "change_book", "view_book", "publish_book"]


class Command(BaseCommand):
    help = "Create the Editors group with its permissions (idempotent)."

    def handle(self, *args, **options):
        group, _ = Group.objects.get_or_create(name="Editors")
        perms = Permission.objects.filter(
            content_type__app_label="catalog", codename__in=EDITORS
        )
        group.permissions.set(perms)  # set() makes re-runs converge
        self.stdout.write(f"Editors: {sorted(p.codename for p in perms)}")
$ python manage.py migrate
$ python manage.py setup_groups
Editors: ['add_book', 'change_book', 'publish_book', 'view_book']

Why not a data migration? Because the permission rows do not exist yet when the migration runs: they are created by the post_migrate signal after all migrations have been applied. In the scratch project a RunPython step that looked for publish_book on a fresh database found nothing (Permission.objects.filter(codename="publish_book").exists() was False), so a migration that builds the group would silently create it empty. If a migration must create groups, call create_permissions() for the app first or accept a command. See Management Commands & the Shell for running it from a deployment script.

Groups are also a free-form label beyond permissions (for example "members of the beta programme"), and a plain check such as user.groups.filter(name="Editors").exists() is legitimate for that. For access control prefer asking for a permission: the view then does not change when the role is reorganised.

Enforcing permissions in views

Function views have two decorators and class-based views have two mixins, one pair for permissions and one pair for arbitrary tests. What matters in practice is what each one does on failure, because the answer differs between anonymous and authenticated users:

Guard Anonymous user Logged-in user lacking access

@permission_required("p")

redirect to LOGIN_URL

redirect to LOGIN_URL as well (the trap below)

@permission_required("p", raise_exception=True)

403

403

PermissionRequiredMixin

redirect to LOGIN_URL

403

PermissionRequiredMixin with raise_exception = True

403

403

@user_passes_test(f)

redirect to LOGIN_URL

redirect to LOGIN_URL

UserPassesTestMixin

redirect to LOGIN_URL

403

PermissionRequiredMixin

The mixin takes a permission or an iterable of them (all are required) and can be customised through the AccessMixin attributes (login_url, redirect_field_name, raise_exception, permission_denied_message):

Views guarded by permissions (catalog/views.py)
from django.contrib.auth.decorators import login_required, permission_required
from django.contrib.auth.mixins import PermissionRequiredMixin
from django.http import HttpResponse
from django.shortcuts import get_object_or_404, redirect
from django.views import View
from django.views.generic import CreateView

from .models import Book


class BookCreateView(PermissionRequiredMixin, CreateView):
    model = Book
    fields = ["title"]  # never "__all__"
    permission_required = "catalog.add_book"
    template_name = "catalog/form.html"
    success_url = "/books/"

    def form_valid(self, form):
        form.instance.owner = self.request.user
        return super().form_valid(form)


class PublishView(PermissionRequiredMixin, View):
    permission_required = "catalog.publish_book"
    raise_exception = True  # always 403, never a redirect to the login page

    def post(self, request, pk):
        book = get_object_or_404(Book, pk=pk)
        book.published = True
        book.save(update_fields=["published"])
        return redirect("/books/")

Note what BookCreateView does not do: the owner is set from request.user and is not a form field, so a posted owner value is ignored. The test that exercises the three roles also checks that:

Anonymous, plain user and editor against BookCreateView
def test_anonymous_redirects_authenticated_gets_403(self):
    r = self.client.get("/books/add/")
    self.assertRedirects(r, "/accounts/login/?next=/books/add/", fetch_redirect_response=False)
    self.client.force_login(self.ann)
    self.assertEqual(self.client.get("/books/add/").status_code, 403)
    self.client.force_login(self.ed)
    self.assertEqual(self.client.get("/books/add/").status_code, 200)

def test_create_sets_owner_and_ignores_owner_field(self):
    self.client.force_login(self.ed)
    r = self.client.post("/books/add/", {"title": "New", "owner": self.bob.pk})
    self.assertEqual(r.status_code, 302)
    self.assertEqual(Book.objects.get(title="New").owner, self.ed)

Override get_permission_required() when the permission depends on the request, and has_permission() to replace the check entirely. A PublishView that answers 403 even to anonymous users is deliberate: it is a POST-only endpoint, and a redirect to a login page is no use to a script. (A state change belongs behind POST and CSRF protection, see Security.)

The permission_required decorator, and the redirect-loop trap

For function views the decorator does the same job. Without raise_exception=True it redirects every failure to the login page, including a user who is already logged in. If the login view then redirects authenticated users away (redirect_authenticated_user=True), the browser bounces between the two pages forever. The documented fix is to stack login_required on top and ask for the 403:

Stacking login_required and permission_required
@login_required
@permission_required("catalog.publish_book", raise_exception=True)
def unpublish(request, pk):
    if request.method != "POST":
        return HttpResponse(status=405)
    Book.objects.filter(pk=pk).update(published=False)
    return redirect("/books/")


@permission_required("catalog.view_book")   # no raise_exception: redirects even logged-in users
def audit(request):
    return HttpResponse("audit")
Observed in the test client
r = self.client.get("/books/audit/")                      # anonymous: 302
self.client.force_login(self.ann)
r = self.client.get("/books/audit/")                      # logged in, no permission: still 302
r = self.client.post(f"/books/{self.pub.pk}/unpublish/")  # decorated stack: 403

If every view of the site (not just the sensitive ones) requires a login, LoginRequiredMiddleware (Django 5.1) states that once and lets @login_not_required opt views out; permissions are then layered on top. It is covered on Authentication.

UserPassesTestMixin: when a permission is not the question

Rules such as "only the owner" or "only on weekdays" are not permissions. The documented tool is test_func(), with @user_passes_test as the function-view equivalent. The Bookshelf owner-only edit view uses it:

Owner-only editing (catalog/views.py)
from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin
from django.views.generic import UpdateView


class OwnerBookUpdateView(LoginRequiredMixin, UserPassesTestMixin, UpdateView):
    model = Book
    fields = ["title"]
    template_name = "catalog/form.html"
    success_url = "/books/"

    def test_func(self):
        return self.get_object().owner == self.request.user
Owner-only editing, tested for each actor
def test_user_passes_test_owner_only(self):
    url = f"/books/{self.draft.pk}/edit/"
    self.assertEqual(self.client.get(url).status_code, 302)        # anonymous: login redirect
    self.client.force_login(self.bob)
    self.assertEqual(self.client.get(url).status_code, 403)
    self.assertEqual(self.client.post(url, {"title": "Hacked"}).status_code, 403)
    self.draft.refresh_from_db()
    self.assertEqual(self.draft.title, "Draft")                      # the database did not change
    self.client.force_login(self.ann)
    self.assertEqual(self.client.post(url, {"title": "Renamed"}).status_code, 302)
    self.draft.refresh_from_db()
    self.assertEqual(self.draft.title, "Renamed")                    # positive control

Three rules apply to UserPassesTestMixin. It runs test_func() in dispatch(), so the test happens before the HTTP method handler. It cannot be stacked: two mixins that each define test_func() do not combine, so one test_func() combines all conditions with and. And get_object() inside test_func() costs a query that the view repeats; when that matters, fetch once and keep the object on self.

The order of the mixins is not cosmetic. LoginRequiredMixin must come first (leftmost), because mixins run in method resolution order and the login check has to stop anonymous requests before any other dispatch() runs. Reversed, the test sees an AnonymousUser:

The wrong order, which the test suite proves crashes
class WrongOrderBookUpdateView(UserPassesTestMixin, LoginRequiredMixin, UpdateView):
    """The mistake: the test runs before the login check."""
    model = Book
    fields = ["title"]

    def test_func(self):
        return self.request.user.email.endswith("@example.com")  # anonymous: AttributeError


def test_wrong_mixin_order_crashes_for_anonymous(self):
    with self.assertRaises(AttributeError):
        self.client.get(f"/books/{self.draft.pk}/edit-wrong/")

With the right order, an anonymous visitor is redirected to the login page; with the wrong one, AnonymousUser has no email and the visitor gets a 500 error page. A test that only logs in as a normal user would never notice.

Object-level permissions

Model permissions answer "may editors change books?". The next question is "may this editor change this book?". Django passes the object through (has_perm(perm, obj), and the same obj argument on every backend method) but its ModelBackend always answers False when an object is given, so core Django supports no object-level permission on its own. There are three options, in increasing weight.

Approach Good for Cost

Ownership test in test_func() or a queryset filter

One rule ("the owner"), few views.

The rule is repeated in every view, API and admin that needs it.

django-rules (predicates)

Rules computed from data you already have: owner, group, status, dates. Rules compose with &, | and ~.

Nothing stored; no way to list "all objects this user can edit" except by filtering yourself.

django-guardian (stored per-object permissions)

Sharing: "give Bob edit access to this one book". Lists objects by permission.

One row per (user or group, object, permission); extra queries; row clean-up when objects go away.

The pitfall: model-level permission does not imply object-level

Once an object is passed, ModelBackend contributes nothing, so a user who holds catalog.change_book through a group is not granted has_perm("catalog.change_book", book) by it. A view that passes the object (django-guardian’s and django-rules' mixins do) must therefore make the "may change all books" case explicit: guardian does it with accept_global_perms, and a rules predicate does it by naming the group or checking the global permission itself. Without that, editors get a 403 on exactly the views meant to serve them. The tests below demonstrate both sides.

django-guardian

django-guardian stores a row per (user or group, object, permission). Install it, add "guardian" to INSTALLED_APPS, add its backend next to ModelBackend and run migrate:

Settings for guardian and rules (config/settings.py)
INSTALLED_APPS += ["guardian", "rules", "catalog"]

AUTHENTICATION_BACKENDS = [
    "django.contrib.auth.backends.ModelBackend",
    "rules.permissions.ObjectPermissionBackend",
    "guardian.backends.ObjectPermissionBackend",
]
$ pip install django-guardian rules

migrate also creates an AnonymousUser row (the name comes from ANONYMOUS_USER_NAME) that stands in for visitors, so anonymous access can be granted per object. Do not delete it. Permissions are then assigned with assign_perm() and read with get_perms(), has_perm() and get_objects_for_user(); the view mixin tests the object that get_object() returns:

A view guarded by an object permission (catalog/views.py)
from guardian.mixins import PermissionRequiredMixin as GuardianPermissionRequiredMixin


class GuardianBookUpdateView(GuardianPermissionRequiredMixin, UpdateView):
    model = Book
    fields = ["title"]
    template_name = "catalog/form.html"
    permission_required = "catalog.change_book"
    raise_exception = True  # logged-in users without the permission get a 403
    success_url = "/books/"
Granting and revoking access to one book
from guardian.shortcuts import assign_perm, get_perms, remove_perm

def test_guardian_assign_and_view(self):
    url = f"/books/{self.draft.pk}/edit-guardian/"
    self.client.force_login(self.bob)
    self.assertEqual(self.client.get(url).status_code, 403)
    assign_perm("catalog.change_book", self.bob, self.draft)
    bob = User.objects.get(pk=self.bob.pk)               # fresh instance: permission cache
    self.assertTrue(bob.has_perm("catalog.change_book", self.draft))
    self.assertFalse(bob.has_perm("catalog.change_book", self.pub))
    self.assertFalse(bob.has_perm("catalog.change_book"))   # no model-level permission
    self.assertEqual(get_perms(bob, self.draft), ["change_book"])
    self.assertEqual(self.client.get(url).status_code, 200)
    remove_perm("catalog.change_book", self.bob, self.draft)
    self.assertEqual(self.client.get(url).status_code, 403)

In the scratch project a logged-in user without the permission was redirected to the login page by this mixin unless raise_exception = True (or return_403 = True) was set, the same redirect-loop trap as with the Django decorator. Permissions can be given to a group (assign_perm(perm, group, obj)), and get_objects_for_user() is the guardian way to scope a queryset; its accept_global_perms argument (default True) is what lets a model-level permission count there:

Listing objects by permission
from guardian.shortcuts import assign_perm, get_objects_for_user

def test_guardian_group_and_listing(self):
    group = Group.objects.create(name="Reviewers")
    self.bob.groups.add(group)
    assign_perm("catalog.view_book", group, self.draft)
    qs = get_objects_for_user(User.objects.get(pk=self.bob.pk), "catalog.view_book", Book)
    self.assertEqual(list(qs), [self.draft])
    # Editors hold the model-level permission, so with accept_global_perms they see everything
    qs = get_objects_for_user(self.ed, "catalog.view_book", Book)
    self.assertEqual(qs.count(), 2)
    self.assertEqual(
        get_objects_for_user(self.ed, "catalog.view_book", Book, accept_global_perms=False).count(), 0
    )

Things to know before adopting it: every object-permission check is a query (use get_objects_for_user() for lists rather than checking object by object, which is the N+1 problem of authorization); and by default guardian adds helper methods to the User and Group classes at start-up (GUARDIAN_MONKEY_PATCH_USER and GUARDIAN_MONKEY_PATCH_GROUP, both True by default), which can be switched off if you prefer the guardian.shortcuts functions. Its documentation is the reference for the other options; this page only claims what was run.

django-rules

django-rules stores nothing. A rule is a function of the user and optionally an object, built with @rules.predicate, combined with &, | and ~, and registered under a permission name with rules.add_perm(). Its ObjectPermissionBackend (in the settings above) lets user.has_perm("catalog.change_review", review) evaluate the rule:

Predicates and permissions (catalog/rules.py)
import rules


@rules.predicate
def is_review_author(user, review):
    return review is not None and review.author_id == user.pk


is_editor = rules.is_group_member("Editors")

rules.add_perm("catalog.change_review", rules.is_authenticated & (is_review_author | is_editor))
rules.add_perm("catalog.delete_review", rules.is_authenticated & (is_review_author | is_editor))

The module must be imported at start-up so that the rules are registered; the scratch project does it from AppConfig.ready() (from . import rules). The view mixin of django-rules passes get_object() to the check:

A view guarded by a rule (catalog/views.py)
from rules.contrib.views import PermissionRequiredMixin as RulesPermissionRequiredMixin


class ReviewUpdateView(RulesPermissionRequiredMixin, UpdateView):
    model = Review
    fields = ["body"]
    template_name = "catalog/form.html"
    permission_required = "catalog.change_review"
    success_url = "/books/"
Author, editor, stranger, and the model-level pitfall
def test_rules_author_only(self):
    review = Review.objects.create(book=self.pub, author=self.bob, body="ok")
    url = f"/books/reviews/{review.pk}/edit/"
    self.assertEqual(self.client.get(url).status_code, 302)     # anonymous
    self.client.force_login(self.ann)
    self.assertEqual(self.client.get(url).status_code, 403)     # stranger
    self.client.force_login(self.bob)
    self.assertEqual(self.client.get(url).status_code, 200)     # author
    self.assertEqual(self.client.post(url, {"body": "better"}).status_code, 302)

def test_rules_editor_and_model_level_permission(self):
    review = Review.objects.create(book=self.pub, author=self.bob, body="ok")
    self.assertTrue(self.ed.has_perm("catalog.change_review", review))   # group member
    mod = User.objects.create_user("mod", password="not-a-real-password")
    mod.user_permissions.add(Permission.objects.get(codename="change_review"))
    mod = User.objects.get(pk=mod.pk)
    self.assertTrue(mod.has_perm("catalog.change_review"))               # model level: ModelBackend
    self.assertFalse(mod.has_perm("catalog.change_review", review))      # object level: nobody grants it
    self.assertTrue(self.bob.has_perm("catalog.change_review", review))  # the author
    self.assertFalse(self.bob.has_perm("catalog.change_review"))         # no model-level permission

The last two assertions of the second test are the pitfall in action: mod has the model-level change_review permission from the default permissions and still fails the object check. The predicate has to say who else may act. Rule names live in the same namespace as Django permissions: registering a rule with a default codename such as catalog.change_review is combined (any backend granting is enough), which is why this example works for the model-level question too, but it also means a rule cannot remove a permission another backend grants. A rule can be tested without a request through rules.has_perm("catalog.change_review", user, review), and in a template the tag {% load rules %} and {% has_perm "catalog.change_book" request.user book as can_edit %} asks the same question, which is what the Bookshelf list template uses to show an "edit" marker.

Asking a rule outside a request
import rules

assert rules.has_perm("catalog.change_review", self.bob, review)
assert not rules.has_perm("catalog.change_review", self.ann, review)

The admin

The admin asks the same questions. ModelAdmin has has_view_permission(), has_add_permission(), has_change_permission() and has_delete_permission(), each of which receives the object (or None on list pages), so an admin can implement per-object rules without any backend; see The Admin Site for the hooks and rules.contrib.admin for a ready-made integration. The admin also needs the view or change permission to show a model at all, and the 2.1 release note about the new view permission is a reminder to grant view, not change, to people who only need to read.

Queryset scoping

A permission check answers yes or no for one request. Scoping answers a different question: which rows a user may see at all. Checking has_perm() in a detail view while the list view returns everything is the most common authorization bug in Django projects (it is called an insecure direct object reference when the leak is by guessable id). The fix is to define the visible set once, on the queryset, and make every access path go through it.

The visible_to() method shown above is that definition. The views use it for both lists and details:

Every access path starts from the scoped queryset (catalog/views.py)
from django.views.generic import DetailView, ListView


class BookListView(ListView):
    template_name = "catalog/list.html"

    def get_queryset(self):
        return Book.objects.visible_to(self.request.user)


class BookDetailView(DetailView):
    template_name = "catalog/detail.html"

    def get_queryset(self):  # scoped: other people's drafts are a 404
        return Book.objects.visible_to(self.request.user)

DetailView looks the object up in get_queryset(), so the scoped queryset turns "not yours" into "does not exist", a 404, and the response does not even confirm that the draft exists. That is the safer default for private data. Answer 403 only when the existence of the object is not a secret (a shared page the user is simply not allowed to edit, as in the owner-only edit view above). The test suite checks both the lists and the detail pages:

Scoping, tested
def test_visible_to(self):
    names = lambda u: sorted(Book.objects.visible_to(u).values_list("title", flat=True))
    self.assertEqual(names(AnonymousUser()), ["Public"])
    self.assertEqual(names(self.bob), ["Public"])
    self.assertEqual(names(self.ann), ["Draft", "Public"])

def test_detail_is_404_not_403_for_hidden(self):
    self.client.force_login(self.bob)
    self.assertEqual(self.client.get(f"/books/{self.draft.pk}/").status_code, 404)
    self.assertEqual(self.client.get(f"/books/{self.pub.pk}/").status_code, 200)

Rules of thumb that follow from this:

  • put the filter in one QuerySet method (see Managers & Custom QuerySets) and call it from every view, API, export, admin class and task that lists, fetches, counts or aggregates the model; a single forgotten Book.objects.all() is a leak

  • use get_object_or_404(Book.objects.visible_to(user), pk=pk) in function views instead of get_object_or_404(Book, pk=pk) followed by a check

  • scope related data too: a review list for a hidden book is as sensitive as the book (Review.objects.filter( book__in=Book.objects.visible_to(user)))

  • do not treat unguessable identifiers (UUIDs) as authorization: they reduce enumeration but are leaked by logs, shared links and referrers; the scoped queryset is what protects the data

  • never trust a user-supplied foreign key: set owner from request.user (as BookCreateView does) and list only the fields that may be edited

  • the admin applies the same idea through ModelAdmin.get_queryset()

The same idea in Django REST framework

The Django REST framework’s DjangoModelPermissions is the API twin of model permissions: it maps POST, PUT/PATCH and DELETE to add, change and delete, and it requires an authenticated user. It does not restrict GET by permission, so on its own it would let any signed-in user read every record of a model. Pair it with a scoped get_queryset():

A scoped ModelViewSet (catalog/api.py)
from rest_framework import permissions, routers, serializers, viewsets

from .models import Book


class BookSerializer(serializers.ModelSerializer):
    class Meta:
        model = Book
        fields = ["id", "title", "published"]  # explicit, never "__all__"
        read_only_fields = ["published"]


class BookViewSet(viewsets.ModelViewSet):
    serializer_class = BookSerializer
    permission_classes = [permissions.DjangoModelPermissions]

    def get_queryset(self):  # scoping: the safe default for every verb
        return Book.objects.visible_to(self.request.user)

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


router = routers.DefaultRouter()
router.register("books", BookViewSet, basename="api-book")
The API, tested
def test_model_permissions_do_not_gate_reads_but_scoping_does(self):
    c = APIClient()
    self.assertIn(c.get("/api/books/").status_code, (401, 403))     # anonymous
    c.force_authenticate(self.bob)
    r = c.get("/api/books/")
    self.assertEqual([b["title"] for b in r.json()], ["Public"])    # no drafts: scoped
    self.assertEqual(c.get(f"/api/books/{self.draft.pk}/").status_code, 404)
    self.assertEqual(c.post("/api/books/", {"title": "x"}, format="json").status_code, 403)
    c.force_authenticate(User.objects.get(pk=self.ed.pk))
    r = c.post("/api/books/", {"title": "x", "owner": self.bob.pk}, format="json")
    self.assertEqual(r.status_code, 201)
    self.assertEqual(Book.objects.get(title="x").owner, self.ed)    # owner field ignored

DRF’s has_object_permission() hook is the object-level equivalent and is only called for single-object lookups (get_object()), never for list responses, which is one more reason to scope the queryset.

Testing authorization

Authorization tests are written to fail when the rule is broken, which asks more of a test than "the page loaded". Four habits keep them honest, and every example above follows them:

  1. Test every actor: anonymous, authenticated without the permission, authenticated with it, and the owner and a stranger for object rules. Anonymous and authenticated-but-forbidden differ (redirect versus 403).

  2. Assert the effect, not only the status: after a forbidden POST, call refresh_from_db() and assert the row is unchanged; for a delete, assert the row still exists.

  3. Add a positive control: the allowed actor must succeed with the same request, otherwise the denial may be an error in the test (a wrong URL name, args passed as a string instead of a list, a missing CSRF-exempt client) and the test passes for the wrong reason.

  4. Use ordinary users, never superusers, for the denial cases (see Checking permissions with has_perm), and fetch a fresh user instance after changing permissions (see the permission cache).

The test client’s force_login() skips the password hashing and the login form, which makes a test suite of this size run in under two seconds. Client.force_login(), override_settings() and the DRF APIClient.force_authenticate() are the tools used here.

Where the books are outdated or wrong

Four Django books were consulted for the topics above. Their authorization chapters are useful for the narrative and the class names, but several statements are outdated and a few examples are insecure or broken. They are corrected here; none of the book text or code is reproduced.

Book pattern Problem Correct form

A custom dispatch() or mixin check placed before LoginRequiredMixin in the inheritance list (Vincent ch. 14)

The check runs first, so anonymous users crash it or bypass the login redirect.

LoginRequiredMixin first, then the permission or test mixin, then the generic view (Enforcing permissions in views).

"Only the author may edit" implemented as a check in the template, or not enforced on POST (Vincent ch. 14)

The edit form is hidden but the URL still works.

UserPassesTestMixin or a scoped queryset on the view, plus POST tests (Enforcing permissions in views, Queryset scoping).

Tests whose POST asserts 200, a "delete" test that never deletes, args='1' instead of args=[1] (Vincent ch. 6)

The test passes whether or not the rule works, or never reaches the code it claims to test.

Assert 302/403, assert the database state, add a positive control (Testing authorization).

fields = "__all__" in model forms and serializers (Vincent ch. 6)

Mass assignment: the visitor chooses owner, published or any other column.

List fields explicitly and set owner server-side (BookCreateView, BookSerializer).

A ModelViewSet with IsAuthenticated only, exposing every customer’s orders (Marani ch. 6)

Any signed-in user lists and edits every record.

A scoped get_queryset() and permissions that match the verbs (Queryset scoping).

User.add_to_class(…​) to add behaviour to the user model (Melé ch. 6)

Monkey-patches the model at import time, breaks with custom user models and migrations, hides state from the type system.

A custom user model from the start, or a proxy model/profile model; see Custom User Model & Backends.

A permission mentioned only in the admin, never checked in views (several books)

The admin enforces change_book for the admin pages only; public views enforce nothing.

Check the same permission in views (Enforcing permissions in views).

No tests for access rules at all (Melé)

Regressions are silent.

One test per actor per rule (Testing authorization).

Outdated rather than wrong: books written for Django 1.x/2.x describe three default permissions (add, change, delete; view arrived in Django 2.1), import django.contrib.auth.mixins classes as if they were new (1.9), and give user.is_authenticated() as a call (a property since 1.10, and the call form was removed in 2.0). All of them predate the async permission methods (5.2) and LoginRequiredMiddleware (5.1).

What changed recently

Version Change

2.1

The default view_<model> permission, and read-only access to models in the admin for users who hold it.

5.1

LoginRequiredMiddleware and @login_not_required: a site-wide "must be logged in" default, with permissions layered on top.

5.2

Async twins of the permission methods: ahas_perm(), ahas_perms(), aget_all_permissions(), aget_user_permissions() on the user, and on ModelBackend.

6.1

migrate renames the default permissions when a model is renamed, and the new Permission.user_perm_str property returns the "app_label.codename" string for has_perm().

Packages

django-guardian 3.5.0 (2026-09-12, Python 3.10+) works on Django 6.1.1 although its classifiers stop at 6.0; django-rules 3.5 (2024-09-02) works on 6.1.1 too. Re-check the classifiers on the project pages before upgrading, as the versions above were verified on 2026-10-03.

Pitfalls checklist

  • Permissions are rows, not behaviour: a Meta.permissions entry enforces nothing until a view checks it.

  • A typo in a permission string is a silent False for ordinary users and a silent True for superusers.

  • Cached permissions: re-fetch the user after changing permissions in tests or long-lived code.

  • @permission_required without raise_exception=True redirects logged-in users to the login page.

  • LoginRequiredMixin goes first.

  • Model-level permission does not grant object-level permission: say so in the rule, or use accept_global_perms.

  • Scope the queryset once and reuse it; prefer a 404 over a 403 for private data.

  • Never trust a foreign key from the request: set it from request.user.

  • Hiding a button is not access control.