Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

Django  Module 6 – Authentication and authorization

Permissions, groups and object-level access

Check model permissions and groups with has_perm, guard views with permission_required, and scope querysets so another resident's bill answers 404.

  • Intermediate
  • 15 minutes
  • Examples run with Python 3.14.8, Django 6.1.2 and Pyodide 314.0.7
  • By MySmartCoPilot
Free lesson, Pro answers: 15 minutes
Free: the whole lesson. With a pass: the model answers to its 2 interview questions.

What you will learn

  • Check model permissions with has_perm() and grant them to roles through groups
  • Protect a view with permission_required, and say what it answers to each visitor
  • Filter querysets to the current user so that another resident's object answers 404
  • Write a test that fails on an insecure direct object reference and passes on the fix
  • Choose between scoped querysets, django-guardian and rules for an object-level rule

Before you start

What you’ll learn · Before you start

What you will learn

  • Check model permissions with has_perm() and grant them to roles through groups
  • Protect a view with permission_required, and say what it answers to each visitor
  • Filter querysets to the current user so that another resident's object answers 404
  • Write a test that fails on an insecure direct object reference and passes on the fix
  • Choose between scoped querysets, django-guardian and rules for an object-level rule

Before you start

On this page

Ravi lives in C-0904, and his maintenance bill is at /bills/2/. If he types /bills/1/ and Nivas shows him the bill of B-1203, every resident’s dues are one number away; OWASP’s Top 10:2025 ranks this kind of hole, broken access control, first.

Django gives you two tools, and this lesson uses both. Model permissions say what a role may do with a kind of object: the committee may mark any bill as paid. Scoped querysets say which objects a person may see: a resident sees the bills of their own flat. You will grant the first through groups, guard views with permission_required, write the second as a queryset method, and prove it with a test that fails on the weak view and passes on the fixed one.

Every model gets four permissions, checked by name

When django.contrib.auth is installed, migrate creates four permissions for every model: add, change, delete and view. A model can declare more in Meta.permissions, as Bill does with mark_paid. A permission is checked by its name, the app label and the codename: user.has_perm('billing.mark_paid'). This program lists them, then checks them for each kind of user:

Permissions, groups and has_perm() in Nivas Python · nivas/check_permissions.py
"""Model permissions in Nivas: the ones migrate creates for billing, the groups that hold them, has_perm() for each
role, the permission cache, and has_perm() with an object.

Run it next to manage.py. It makes a test database in memory, as manage.py test does."""

import datetime
import os

os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')

import django  # noqa: E402

django.setup()

from django.contrib.auth import get_user_model  # noqa: E402
from django.contrib.auth.models import Group, Permission  # noqa: E402
from django.test.utils import setup_databases, setup_test_environment  # noqa: E402

from billing.models import Bill, Flat  # noqa: E402

setup_test_environment()
setup_databases(verbosity=0, interactive=False, aliases={'default'})  # migrate: creates the permissions and groups
Resident = get_user_model()

print('1. The permissions of billing.Bill, created by migrate')
for permission in Permission.objects.filter(content_type__model='bill').order_by('codename'):
    print(f'   {permission.user_perm_str:22} {permission.name}')
print('2. The groups made after migrate')
for group in Group.objects.order_by('name'):
    print(f'   {group.name:10} {", ".join(sorted(p.codename for p in group.permissions.all()))}')

asha = Resident.objects.create_user('98000 00001', 'Asha Rao')
meera = Resident.objects.create_user('98000 00005', 'Meera Pillai')
meera.groups.add(Group.objects.get(name='committee'))
admin = Resident.objects.create_superuser('98000 00099', 'Committee desk')
moved = Resident.objects.create_superuser('98000 00098', 'Former secretary', is_active=False)

print('3. has_perm() for each role')
for person in [asha, meera, admin, moved]:
    person = Resident.objects.get(pk=person.pk)
    checks = ', '.join(f'{perm} {person.has_perm(perm)}' for perm in ['billing.view_bill', 'billing.mark_paid'])
    print(f'   {person.name:16} {checks}')

print('4. The permission cache')
ravi = Resident.objects.create_user('98000 00002', 'Ravi Iyer')
print(f'   before:                       billing.mark_paid {ravi.has_perm("billing.mark_paid")}')
ravi.groups.add(Group.objects.get(name='committee'))
print(f'   added to committee, same obj: billing.mark_paid {ravi.has_perm("billing.mark_paid")}')
ravi.refresh_from_db()
print(f'   after refresh_from_db():      billing.mark_paid {ravi.has_perm("billing.mark_paid")}')
ravi = Resident.objects.get(pk=ravi.pk)
print(f'   fetched again:                billing.mark_paid {ravi.has_perm("billing.mark_paid")}')

print('5. has_perm() with an object')
bill = Bill.objects.create(flat=Flat.objects.create(code='B-1203'), month=datetime.date(2026, 9, 1), amount_paise=245_000)
meera = Resident.objects.get(pk=meera.pk)
print(f'   Meera, billing.change_bill:        {meera.has_perm("billing.change_bill")}')
print(f'   Meera, billing.change_bill, bill:  {meera.has_perm("billing.change_bill", bill)}')
print(f'   superuser, billing.change_bill, bill: {admin.has_perm("billing.change_bill", bill)}')

Output

1. The permissions of billing.Bill, created by migrate
   billing.add_bill       Can add bill
   billing.change_bill    Can change bill
   billing.delete_bill    Can delete bill
   billing.mark_paid      Can mark a bill as paid
   billing.view_bill      Can view bill
2. The groups made after migrate
   committee  change_bill, mark_paid, view_bill, view_flat
   guard      view_flat
3. has_perm() for each role
   Asha Rao         billing.view_bill False, billing.mark_paid False
   Meera Pillai     billing.view_bill True, billing.mark_paid True
   Committee desk   billing.view_bill True, billing.mark_paid True
   Former secretary billing.view_bill False, billing.mark_paid False
4. The permission cache
   before:                       billing.mark_paid False
   added to committee, same obj: billing.mark_paid False
   after refresh_from_db():      billing.mark_paid False
   fetched again:                billing.mark_paid True
5. has_perm() with an object
   Meera, billing.change_bill:        True
   Meera, billing.change_bill, bill:  False
   superuser, billing.change_bill, bill: True

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 check_permissions.py

The first part printed each permission’s user_perm_str, new in Django 6.1, which gives exactly the string has_perm() takes, so you no longer build it from the app label and the codename by hand. Then step through how has_perm() reached each answer of the run:

Interactive · stage by stage How has_perm() answers for Meera, Asha and an object Step through the checks of the recorded run, from a committee member to a check with an object.
How has_perm() answers for Meera, Asha and an object, step 6 of 6. Showing user.has_perm(perm, obj) ('billing.change_bill', bill), Active superuser?, Ask each backend in turn, ModelBackend checks (empty for any object), True or False (False). In focus: user.has_perm(perm, obj), ModelBackend checks, True or False.yes: Trueuser.has_perm(perm, obj)'billing.change_bill', billActive superuser?answered before any backendAsk each backend in turnAUTHENTICATION_BACKENDSModelBackend checksempty for any objectTrue or FalseFalse

The drawing shows the whole scene; its 6 steps are listed below.

Every step as text
  1. Meera asks for mark_paid. Meera is on the committee, and the check is has_perm('billing.mark_paid'). She is not a superuser, so the question goes to the backends.
  2. Her group grants it. ModelBackend collects her own permissions and those of her groups, as 'app_label.codename' strings. The committee group holds mark_paid, so the answer is True.
  3. Asha holds none. Asha is in no group and has no permission of her own, so the set is empty and has_perm('billing.view_bill') is False. She still sees her own bills, through the scoped queryset.
  4. The superuser shortcut. For an active superuser, has_perm() answers True before any backend is asked, for any permission name at all. The former secretary is a superuser but inactive, so his checks went to the backends and got False.
  5. The set is cached. ModelBackend keeps the set on the user object. Ravi was added to the committee after a first check, and the same object, refresh_from_db() included, still answered False; only a freshly fetched user answered True.
  6. With an object. has_perm('billing.change_bill', bill) asks about one bill. ModelBackend has no object permissions and answers with an empty set for any object, so Meera got False; only the superuser shortcut said True.

Three results deserve a second look. An active superuser gets True for every permission name, so superuser accounts are for the few people who run the site. The permission cache: The set of permission names ModelBackend stores on a user object at its first check and reuses afterwards; only a user fetched again from the database sees permissions granted since. kept Ravi’s old answer even after refresh_from_db(), as Django’s documentation warns; only a freshly fetched user saw the new group, which matters in tests that grant and then check. And has_perm() with an object answered False for Meera: Django’s ModelBackend has no object permissions, so it answers with an empty set for any object.

Asking has_perm() about one bill

if request.user.has_perm('billing.change_bill', bill): is False for everyone but superusers with Django’s own backend, so a view written this way works only for the people who test it as superusers. Check the model permission without the object, and decide about the object with a scoped queryset.

Checkpoint

Check: Model permissions and has_perm()

  1. Question 1 of 2 Bill lives in the billing app and declares Meta.permissions = [("mark_paid", "Can mark a bill as paid")]. Which string does has_perm() take for it?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: 'billing.mark_paid'

    Every permission is checked as "app_label.codename". Django 6.1's Permission.user_perm_str returns exactly that string.

  2. Question 2 of 2 Meera of the committee holds billing.change_bill. With only ModelBackend, what is meera.has_perm("billing.change_bill", bill)?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: False, because ModelBackend answers with an empty set for any object

    Object permissions need a backend that implements them, such as django-guardian's or rules'. With Django's own, only an active superuser gets True with an object.

Groups hold the permissions of a role

Giving permissions to people one at a time does not survive a change of committee. Give them to a group: A named set of permissions in django.contrib.auth; a user has every permission of each group they belong to, so a group stands for a role such as a committee. that stands for a role, then add people to the group: a member has every permission of each group they are in. Nivas has two roles beside the residents:

Group Permissions of billing What they do
committee view_flat, view_bill, change_bill, mark_paid see every flat’s bills and record payments
guard view_flat find the flat a visitor is going to
(residents) none see their own flat’s bills, through the scoped queryset of the next sections

The groups are code, so every database, test database and new developer gets the same ones. The permissions themselves are created by a post_migrate: The signal Django sends for each app after migrate has run; django.contrib.auth creates the models' permissions in a receiver of it, so code that needs those permissions listens to it too. receiver of django.contrib.auth, after the migrations have run, so BillingConfig connects its own receiver to the same signal and makes the groups there, with get_or_create so it can run on every migrate:

The roles of Nivas, made after every migrate Python · nivas/billing/apps.py
from django.apps import AppConfig
from django.db.models.signals import post_migrate

# The roles of Nivas, as groups, and the permissions of billing that each one gets.
ROLES = {
    'committee': ['view_flat', 'view_bill', 'change_bill', 'mark_paid'],
    'guard': ['view_flat'],
}


def make_roles(sender, **kwargs):
    """Runs after every migrate, when Django has created the permissions; get_or_create makes it safe to run again."""
    from django.contrib.auth.models import Group, Permission

    for name, codenames in ROLES.items():
        group, _ = Group.objects.get_or_create(name=name)
        group.permissions.set(Permission.objects.filter(content_type__app_label='billing', codename__in=codenames))


class BillingConfig(AppConfig):
    default_auto_field = 'django.db.models.BigAutoField'
    name = 'billing'

    def ready(self):
        post_migrate.connect(make_roles, sender=self)

The run’s second part printed the result: the committee group with its four permissions, the guard group with one. Meera, fetched again after joining the committee, had all of its permissions without a single one given to her by name.

Key idea

A permission says what may be done to a kind of object; a group bundles the permissions of a role; people get them by joining the group, and nobody gets them by name.

Checkpoint

Check: Groups as roles

  1. One question A new secretary joins the committee. How should Nivas give her the committee's permissions?

    Choose one answer.

    Show the answer: One question

    Answer: Add her to the committee group, which holds the permissions

    Permissions belong to roles and people belong to roles. Groups make that a single place to change.

A decorator guards the view, never the object

@permission_required('billing.mark_paid') lets a request reach the view only when the user has the permission. Put @login_required above it and give it raise_exception=True, as the documentation suggests: a visitor who has not signed in is then sent to the login page, and a signed-in resident without the permission gets 403 Forbidden instead of a login page that would only send them round again. For class-based views, PermissionRequiredMixin does the same with permission_required = 'billing.mark_paid'. The views of the bill pages:

The bill views and the queryset method they share

billing/views.py · nivas/billing/views.py

from django.contrib.auth.decorators import login_required, permission_required
from django.shortcuts import get_object_or_404, redirect, render
from django.views.decorators.http import require_POST

from .models import Bill


@login_required
def bill_list(request):
    return render(request, 'billing/list.html', {'bills': Bill.objects.visible_to(request.user)})


@login_required
def bill_detail(request, pk):
    # Looked up inside the bills this user may see: another flat's bill is a 404, like a bill that does not exist.
    bill = get_object_or_404(Bill.objects.visible_to(request.user), pk=pk)
    return render(request, 'billing/detail.html', {'bill': bill})


@require_POST
@login_required
@permission_required('billing.mark_paid', raise_exception=True)  # signed in without the permission: 403
def mark_paid(request, pk):
    bill = get_object_or_404(Bill, pk=pk)  # the permission covers every bill, so no scoping is needed here
    bill.paid = True
    bill.save(update_fields=['paid'])
    return redirect('bill-detail', pk=bill.pk)

billing/models.py · nivas/billing/models.py

from django.conf import settings
from django.db import models


class Flat(models.Model):
    code = models.CharField(max_length=8, unique=True)
    members = models.ManyToManyField(settings.AUTH_USER_MODEL, related_name='flats', blank=True)

    def __str__(self):
        return self.code


class BillQuerySet(models.QuerySet):
    def visible_to(self, user):
        """The bills a user may see: all of them with billing.view_bill (the committee), else their own flats' bills."""
        if user.has_perm('billing.view_bill'):
            return self
        return self.filter(flat__members=user)


class Bill(models.Model):
    flat = models.ForeignKey(Flat, on_delete=models.PROTECT, related_name='bills')
    month = models.DateField()
    amount_paise = models.PositiveBigIntegerField()
    paid = models.BooleanField(default=False)

    objects = BillQuerySet.as_manager()

    class Meta:
        ordering = ['flat__code', 'month']
        permissions = [('mark_paid', 'Can mark a bill as paid')]  # a custom permission, beside the four defaults

    def __str__(self):
        return f'{self.flat} {self.month:%Y-%m}'

This program visits the pages as four people: nobody, Asha of B-1203, Ravi of C-0904 and Meera of the committee.

The bill pages for four visitors Python · nivas/check_views.py
"""The bill pages of Nivas for four visitors: nobody signed in, Asha of B-1203, Ravi of C-0904 and Meera of the
committee.

Run it next to manage.py. It makes a test database in memory, as manage.py test does."""

import datetime
import logging
import os

os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')

import django  # noqa: E402

django.setup()

from django.contrib.auth import get_user_model  # noqa: E402
from django.contrib.auth.models import Group  # noqa: E402
from django.test import Client  # noqa: E402
from django.test.utils import setup_databases, setup_test_environment  # noqa: E402

from billing.models import Bill, Flat  # noqa: E402

setup_test_environment()
setup_databases(verbosity=0, interactive=False, aliases={'default'})
logging.getLogger('django.request').setLevel(logging.CRITICAL)  # the 403 and 404 answers below are expected
Resident = get_user_model()
asha = Resident.objects.create_user('98000 00001', 'Asha Rao')
ravi = Resident.objects.create_user('98000 00002', 'Ravi Iyer')
meera = Resident.objects.create_user('98000 00005', 'Meera Pillai')
meera.groups.add(Group.objects.get(name='committee'))
b1203, c0904 = Flat.objects.create(code='B-1203'), Flat.objects.create(code='C-0904')
b1203.members.add(asha)
c0904.members.add(ravi)
september = datetime.date(2026, 9, 1)
Bill.objects.create(flat=b1203, month=september, amount_paise=245_000)  # bill 1
Bill.objects.create(flat=c0904, month=september, amount_paise=198_000)  # bill 2


def visit(person, method, path):
    client = Client()
    if person:
        client.force_login(person)
    response = getattr(client, method)(path)
    answer = f'302 to {response["Location"]}' if response.status_code == 302 else f'{response.status_code} {response.reason_phrase}'
    if response.status_code == 200 and 'bills' in (response.context or {}):
        answer += f': {", ".join(str(b) for b in response.context["bills"])}'
    print(f'   {person.name if person else "nobody":13} {method.upper():4} {path:22} {answer}')


print('1. The list: each resident sees their own flat, the committee every flat')
for person in [None, asha, ravi, meera]:
    visit(person, 'get', '/bills/')
print('2. A bill by its number')
visit(asha, 'get', '/bills/1/')
visit(ravi, 'get', '/bills/1/')
visit(ravi, 'get', '/bills/999/')
visit(meera, 'get', '/bills/1/')
print('3. Marking a bill as paid needs billing.mark_paid')
visit(None, 'post', '/bills/1/mark-paid/')
visit(asha, 'post', '/bills/1/mark-paid/')
visit(meera, 'post', '/bills/1/mark-paid/')

Output

1. The list: each resident sees their own flat, the committee every flat
   nobody        GET  /bills/                302 to /accounts/login/?next=/bills/
   Asha Rao      GET  /bills/                200 OK: B-1203 2026-09
   Ravi Iyer     GET  /bills/                200 OK: C-0904 2026-09
   Meera Pillai  GET  /bills/                200 OK: B-1203 2026-09, C-0904 2026-09
2. A bill by its number
   Asha Rao      GET  /bills/1/              200 OK
   Ravi Iyer     GET  /bills/1/              404 Not Found
   Ravi Iyer     GET  /bills/999/            404 Not Found
   Meera Pillai  GET  /bills/1/              200 OK
3. Marking a bill as paid needs billing.mark_paid
   nobody        POST /bills/1/mark-paid/    302 to /accounts/login/?next=/bills/1/mark-paid/
   Asha Rao      POST /bills/1/mark-paid/    403 Forbidden
   Meera Pillai  POST /bills/1/mark-paid/    302 to /bills/1/

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 check_views.py

The third part of the run is the decorator at work: a redirect for nobody, 403 for Asha, and for Meera the bill was marked paid. Look at what the decorator does not do, though: it never looks at which bill. mark_paid may stay unscoped only because the permission is meant to cover every bill. A permission like “may see bills” says nothing about whose bills, and that is the hole of the next section.

Checkpoint

Check: permission_required on a view

  1. One question mark_paid has @login_required above @permission_required("billing.mark_paid", raise_exception=True). What did Asha, signed in without the permission, get?

    Choose one answer.

    Show the answer: One question

    Answer: 403 Forbidden

    @login_required sends visitors to sign in; @permission_required with raise_exception gives signed-in users without the permission a 403, rather than a login page that would send them round again.

Scope the queryset, and another flat’s bill is a 404

An object-level rule, such as “a resident sees the bills of their own flat”, lives in your own code. Nivas keeps it in one place, BillQuerySet.visible_to(user): every bill for a user with billing.view_bill, otherwise filter(flat__members=user). The list uses it, and the detail page looks the bill up inside it: get_object_or_404(Bill.objects.visible_to(request.user), pk=pk). The run’s first two parts show the effect: Asha’s list held only B-1203, Ravi’s only C-0904, and Ravi’s request for bill 1 answered 404 Not Found.

The weak version, views_weak.py, looks the bill up with get_object_or_404(Bill, pk=pk) and shows it to anyone who is signed in. That is an insecure direct object reference (IDOR): A page or API that loads an object by an identifier taken from the request without checking that the person asking may see it, so changing the number shows other people's data., the hole OWASP describes as permitting someone to view or edit another account’s data by giving its identifier. Step through Ravi’s request on both views:

Interactive · message by message One request for another flat's bill, two views Step through Ravi's request for bill 1, first on the weak page, then on the scoped one, then for a bill that does not exist.
One request for another flat's bill, two views, step 6 of 6. Messages so far: Ravi's browser to bill_detail: GET /bills/1/; bill_detail to Database: WHERE id = 1; bill_detail to Ravi's browser: 200: B-1203's bill; bill_detail to Database: id = 1 AND member = Ravi; bill_detail to Ravi's browser: 404 Not Found; bill_detail to Ravi's browser: 404 Not Found.Ravi's browserflat C-0904bill_detailviews.bill_detailDatabaseGET /bills/1/WHERE id = 1200: B-1203's billid = 1 AND member = Ravi404 Not Found404 Not Foundnot yours and not there: one answer

The drawing shows the whole scene; its 6 steps are listed below.

Every step as text
  1. Ravi changes the number. Ravi is signed in, and his own bill is /bills/2/. He types /bills/1/ instead. @login_required lets him through: he is signed in, and that is all it checks.
  2. The weak lookup. views_weak.bill_detail calls get_object_or_404(Bill, pk=1). The query asks only for id 1, so the database returns the bill of B-1203.
  3. 200, and the hole. The page answers 200 with Asha's bill. This is an insecure direct object reference: the address names an object and nothing asks whether this user may see it.
  4. The scoped lookup. views.bill_detail looks inside Bill.objects.visible_to(user). Ravi has no billing.view_bill, so the query also requires that he is a member of the bill's flat.
  5. No row, so 404. No bill matches both conditions, so get_object_or_404() raises Http404 and the page answers 404 Not Found.
  6. A bill that does not exist. A request for /bills/999/ gets the same 404. Ravi cannot tell a bill he may not see from one that does not exist, so the page tells him nothing about other flats.

A 404 rather than 403 is a choice: it also hides whether bill 1 exists, so the answers tell Ravi nothing about other flats. A test belongs in every suite with per-user data, and OWASP asks for access control in unit and integration tests. This one requests Asha’s bill as Ravi:

The access test of the bill page Python · nivas/billing/tests.py
import datetime

from django.contrib.auth import get_user_model
from django.test import TestCase

from .models import Bill, Flat


class BillAccessTests(TestCase):
    """Who may read a bill: the test every suite with per-user data needs (OWASP Top 10:2025, A01)."""

    @classmethod
    def setUpTestData(cls):
        Resident = get_user_model()
        cls.asha = Resident.objects.create_user('98000 00001', 'Asha Rao')  # no password: the tests use force_login
        cls.ravi = Resident.objects.create_user('98000 00002', 'Ravi Iyer')
        b1203 = Flat.objects.create(code='B-1203')
        c0904 = Flat.objects.create(code='C-0904')
        b1203.members.add(cls.asha)
        c0904.members.add(cls.ravi)
        cls.ashas_bill = Bill.objects.create(flat=b1203, month=datetime.date(2026, 9, 1), amount_paise=245_000)

    def test_a_resident_sees_her_own_bill(self):
        self.client.force_login(self.asha)
        self.assertEqual(self.client.get(f'/bills/{self.ashas_bill.pk}/').status_code, 200)

    def test_another_flats_bill_is_not_found(self):
        self.client.force_login(self.ravi)
        response = self.client.get(f'/bills/{self.ashas_bill.pk}/')
        self.assertEqual(response.status_code, 404, "Ravi must not see B-1203's bill")

    def test_a_bill_that_does_not_exist_is_not_found(self):
        self.client.force_login(self.ravi)
        self.assertEqual(self.client.get('/bills/999/').status_code, 404)

This program runs the same three tests against the weak page and against the scoped one:

The same tests against the weak and the scoped bill page Python · nivas/check_idor.py
"""billing/tests.py run twice: against the weak bill page of billing/views_weak.py (config/urls_weak.py), then against
the scoped one of billing/views.py (config/urls.py). The same test must fail on the first and pass on the second.

Run it next to manage.py. It makes a test database in memory, as manage.py test does."""

import logging
import os
import unittest

os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')

import django  # noqa: E402

django.setup()

from django.test.utils import override_settings, setup_databases, setup_test_environment  # noqa: E402

from billing.tests import BillAccessTests  # noqa: E402

setup_test_environment()
setup_databases(verbosity=0, interactive=False, aliases={'default'})
logging.getLogger('django.request').setLevel(logging.CRITICAL)  # the 404 answers are expected

for urlconf, page in [('config.urls_weak', 'the weak bill page'), ('config.urls', 'the scoped bill page')]:
    print(f'billing/tests.py against {page} ({urlconf}):')
    with override_settings(ROOT_URLCONF=urlconf):
        result = unittest.TestResult()
        unittest.defaultTestLoader.loadTestsFromTestCase(BillAccessTests).run(result)
    failed = {test.id().rsplit('.', 1)[1]: trace.strip().splitlines()[-1] for test, trace in result.failures}
    for name in sorted(n for n in dir(BillAccessTests) if n.startswith('test_')):
        print(f'   {name} ... {"FAIL: " + failed[name] if name in failed else "ok"}')
    print(f'   {result.testsRun} tests, {len(result.failures)} failed')

Output

billing/tests.py against the weak bill page (config.urls_weak):
   test_a_bill_that_does_not_exist_is_not_found ... ok
   test_a_resident_sees_her_own_bill ... ok
   test_another_flats_bill_is_not_found ... FAIL: AssertionError: 200 != 404 : Ravi must not see B-1203's bill
   3 tests, 1 failed
billing/tests.py against the scoped bill page (config.urls):
   test_a_bill_that_does_not_exist_is_not_found ... ok
   test_a_resident_sees_her_own_bill ... ok
   test_another_flats_bill_is_not_found ... ok
   3 tests, 0 failed

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 check_idor.py

The weak page passed two tests and failed test_another_flats_bill_is_not_found with 200 instead of 404; the scoped page passed all three. A test that fails on the hole and passes on the fix is the one that keeps the hole from coming back, when someone writes a new view and forgets the scope.

Key idea

Look objects up inside a queryset that already holds only what this user may see; then “not yours” and “not there” are the same 404, and a test that requests another flat’s object proves it.

Checkpoint

Check: Scoped querysets and the 404

  1. Question 1 of 2 Which line closes the hole of views_weak.bill_detail?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: bill = get_object_or_404(Bill.objects.visible_to(request.user), pk=pk)

    Scope the queryset, then look up inside it. The run showed 200 for Asha's own bill and 404 for Ravi's request for it.

  2. Question 2 of 2 Why does the scoped bill page answer 404, and not 403, when Ravi asks for bill 1?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: A 404 is also what a bill that does not exist gets, so the answer does not reveal that bill 1 exists

    "Not yours" and "not there" give the same answer when the lookup is scoped, which keeps other flats' bills from being counted or probed by number.

Object rules in your code, in the database, or as predicates

Scoping in querysets is the way Nivas decides object access, and it suits rules that follow from the data. Two libraries cover the other cases. django-guardian stores permissions for single objects: assign_perm('change_bill', meera, bill) writes a grant, its extra backend makes has_perm() answer for an object, and get_objects_for_user() returns the objects a user holds permissions for. rules keeps permissions as predicates, small functions of the user and the object, checked by its own backend without any database table. Choose by where the answer comes from:

Interactive · answer and compare Where should an object-level rule live? Answer two questions about your rule; the table marks the ways that fit and the ones the answers rule out.
Does a relationship in your data already decide access, such as membership of the bill's flat?
Must list pages show only what each user may see?

Answer the questions above: the table then marks what fits your case and says why the rest does not.

Three ways to decide who may see one object, against the four things that decide between them
Way Where the answer comes from List pages user.has_perm(perm, obj) What it costs
Scoped querysets Decides with: a filter in your own code, such as visible_to() Good relationships the data already has, such as flat membership Good the same filter serves the list and the detail page Poor not used: ModelBackend answers False for any object Good one method on a QuerySet, and tests
django-guardian Decides with: grants stored per object, as rows Mixed explicit grants: assign_perm(perm, user_or_group, obj) Good a helper returns the objects a user holds a permission for Good its extra backend answers has_perm() for an object Mixed a grant row per user or group and object, kept in step with the data
rules Decides with: predicates in code, no database Good any condition you can write as a function of the user and the object Poor a predicate checks one object, so a list still needs its own filter Good its backend answers has_perm() with the predicate Good no tables; the rule lives in code
How the answers decide
  • “Does a relationship in your data already decide access, such as membership of the bill's flat?” Yes, the data says it: adds a caveat to django-guardian. A grant row per object repeats what the data already says, and must be updated whenever a membership changes.
  • “Does a relationship in your data already decide access, such as membership of the bill's flat?” No, people grant access one object at a time: rules out Scoped querysets and rules. Grants made by hand are data that no filter or predicate can work out; they have to be stored, which is what django-guardian does.
  • “Must list pages show only what each user may see?” Yes: adds a caveat to rules. A predicate answers for one object; list pages still need a queryset filter that agrees with it.

A relationship the data already holds, such as flat membership, is best scoped in a queryset, and grants made by hand, such as “this auditor may read these five bills”, need stored grants. Whichever you choose, the list pages need a filter that agrees with the rule, and the access tests run against both.

Checkpoint

Check: Where object rules live

  1. One question An auditor must read five particular bills, picked by the committee by hand. Where does that rule live best?

    Choose one answer.

    Show the answer: One question

    Answer: In stored grants, as django-guardian keeps them, because no relationship in the data says which five

    Rules that follow from the data are queryset filters or predicates; access granted by hand is data and needs storing per object.

Practise: three lookups that trust the address

The starter has three lookups with the hole of views_weak.py: bills, complaints and visitor passes. Close them so each returns its object only to the people who may see it, with sample tests that run in your browser:

Exercise · Medium · Python

Close three lookups that trust the number in the address

Three pages of a Nivas starter look an object up by the number in the address and show it to anyone who is signed in, the hole of billing/views_weak.py. Their lookups are plain Python here: BILLS, COMPLAINTS and PASSES map an id to a dictionary, and a user is a dictionary such as {'name': 'Ravi', 'flats': {'C-0904'}, 'committee': False}. Rewrite the three functions so each returns the object only to the people who may see it, and raises NotFound for everyone else, exactly as for an id that does not exist:

  • bill_for(user, bill_id): a member of the bill's flat, or anyone on the committee.
  • complaint_for(user, complaint_id): the resident who reported it (complaint['reporter'] is their name), or anyone on the committee.
  • visitor_pass_for(user, pass_id): a member of the pass's flat only. Who visits a flat is that flat's business, so the committee sees no other flat's visitors.

The same error for "not yours" and "not there" is the point: like a 404 from a scoped queryset, it tells nobody which ids exist.

Starter code · lookups.py

class NotFound(Exception):
    """What a lookup raises for an object that does not exist or that this user may not see (a 404)."""


BILLS = {1: {'flat': 'B-1203', 'amount_paise': 245_000}, 2: {'flat': 'C-0904', 'amount_paise': 198_000}}
COMPLAINTS = {7: {'flat': 'B-1203', 'reporter': 'Asha', 'text': 'Lift B stops between floors 7 and 8'}}
PASSES = {31: {'flat': 'C-0904', 'visitor': 'Plumber, Saturday 10:00'}}


def bill_for(user, bill_id):
    return BILLS[bill_id]


def complaint_for(user, complaint_id):
    return COMPLAINTS[complaint_id]


def visitor_pass_for(user, pass_id):
    return PASSES[pass_id]
The sample tests · test_lookups.py
from lookups import NotFound, bill_for, complaint_for, visitor_pass_for

ASHA = {'name': 'Asha', 'flats': {'B-1203'}, 'committee': False}
RAVI = {'name': 'Ravi', 'flats': {'C-0904'}, 'committee': False}
MEERA = {'name': 'Meera', 'flats': {'A-0101'}, 'committee': True}
LEELA = {'name': 'Leela', 'flats': {'B-1203'}, 'committee': False}  # lives in B-1203 with Asha


def not_found(lookup, user, object_id):
    try:
        lookup(user, object_id)
    except NotFound:
        return True
    return False


def test_residents_see_their_own_flats_bills():
    assert bill_for(ASHA, 1)['flat'] == 'B-1203'
    assert bill_for(RAVI, 2)['flat'] == 'C-0904'


def test_another_flats_bill_is_not_found():
    assert not_found(bill_for, RAVI, 1)
    assert not_found(bill_for, ASHA, 2)


def test_the_committee_sees_every_bill():
    assert bill_for(MEERA, 1)['flat'] == 'B-1203'
    assert bill_for(MEERA, 2)['flat'] == 'C-0904'


def test_a_complaint_belongs_to_its_reporter():
    assert complaint_for(ASHA, 7)['reporter'] == 'Asha'
    assert complaint_for(MEERA, 7)['reporter'] == 'Asha'
    assert not_found(complaint_for, LEELA, 7)  # same flat, not the reporter
    assert not_found(complaint_for, RAVI, 7)


def test_visitor_passes_stay_within_their_flat():
    assert visitor_pass_for(RAVI, 31)['flat'] == 'C-0904'
    assert not_found(visitor_pass_for, ASHA, 31)
    assert not_found(visitor_pass_for, MEERA, 31)  # the committee too


def test_missing_ids_are_not_found_for_everyone():
    for lookup, missing in [(bill_for, 99), (complaint_for, 99), (visitor_pass_for, 99)]:
        assert not_found(lookup, MEERA, missing)
        assert not_found(lookup, ASHA, missing)


def test_not_yours_and_not_there_raise_the_same_error():
    errors = []
    for object_id in [1, 99]:
        try:
            bill_for(RAVI, object_id)
        except Exception as error:
            errors.append(type(error))
    assert errors == [NotFound, NotFound]


def test_a_resident_of_two_flats_sees_both():
    owner = {'name': 'Kabir', 'flats': {'B-1203', 'C-0904'}, 'committee': False}
    assert bill_for(owner, 1)['flat'] == 'B-1203'
    assert visitor_pass_for(owner, 31)['flat'] == 'C-0904'
A hint

Look the object up with .get(), which gives None for an id that does not exist, then decide with one condition whether this user may see it. Raise NotFound in both cases with the same raise NotFound(...), so the two failures cannot be told apart.

The sample tests run on this device, in your browser (Pyodide): nothing is sent to mysmartcopilot.com. The first run downloads Python (about 13.5 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.

Then work through the five ideas of this lesson, from permission names to the libraries. Practice starts with the idea you know least and explains every choice:

Practice that adapts to you

Practise this lesson

11 questions about 5 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.

  1. Question 1 of 11 Which permissions does migrate create for a model without Meta.permissions?

    Choose one answer.

    Show the answer: Question 1 of 11

    Answer: add, change, delete and view

    With django.contrib.auth installed, the four default permissions are created by a post_migrate receiver for each model of every installed app.

  2. Question 2 of 11 A test adds Ravi to the committee group, then calls ravi.refresh_from_db() and checks ravi.has_perm("billing.mark_paid"). Why did the run print False?

    Choose one answer.

    Show the answer: Question 2 of 11

    Answer: ModelBackend cached his permissions on the object at the first check, and refresh_from_db() does not clear that cache

    Permission checks cache their answer set per user object. In tests and views that grant and then check, fetch the user again.

  3. Question 3 of 11 What does has_perm("billing.anything_at_all") return for an active superuser?

    Choose one answer.

    Show the answer: Question 3 of 11

    Answer: True, for any permission name, even one that does not exist

    Active superusers pass every permission check, which is why superuser accounts are for the few people who run the site; an inactive superuser passes none.

  4. Question 4 of 11 Why does Nivas create its groups in a post_migrate receiver rather than by hand in the admin?

    Choose one answer.

    Show the answer: Question 4 of 11

    Answer: Every database, test database and developer then gets the same roles, made after the permissions exist

    Roles are part of the application, so they belong in code and in every test database; post_migrate runs after the permissions they hold exist.

  5. Question 5 of 11 Kabir is in the guard group and the committee group. Which billing permissions does he have?

    Choose one answer.

    Show the answer: Question 5 of 11

    Answer: All of the committee's and the guard's together

    ModelBackend takes the union of a user's own permissions and those of all their groups.

  6. Question 6 of 11 What did the run give nobody signed in for POST /bills/1/mark-paid/?

    Choose one answer.

    Show the answer: Question 6 of 11

    Answer: 302 to /accounts/login/?next=/bills/1/mark-paid/

    The decorators run from the top: require_POST, then login_required, then permission_required.

  7. Question 7 of 11 A class-based view must require billing.mark_paid. What is the class-based equivalent of the decorator?

    Choose one answer.

    Show the answer: Question 7 of 11

    Answer: PermissionRequiredMixin with permission_required = "billing.mark_paid"

    PermissionRequiredMixin is the mixin form of permission_required; the lesson on class-based views and mixins shows where in the class it must go.

  8. Question 8 of 11 What did Ravi of C-0904 see on /bills/, the list?

    Choose one answer.

    Show the answer: Question 8 of 11

    Answer: Only the bill of C-0904

    One queryset method serves the list and the detail page, so both show the same bills to the same person.

  9. Question 9 of 11 Which test would have caught the hole of views_weak.bill_detail before release?

    Choose one answer.

    Show the answer: Question 9 of 11

    Answer: Sign in as Ravi, request Asha's bill and assert 404

    An access test must try what an attacker would: another user's object. Tests of one's own data pass on the hole.

  10. Question 10 of 11 What does the rules library keep a permission as?

    Choose one answer.

    Show the answer: Question 10 of 11

    Answer: A predicate, a function of the user and the object, checked by its backend without a database table

    django-guardian stores grants per object; rules computes the answer from a predicate; scoped querysets filter lists and lookups.

  11. Question 11 of 11 Nivas switches its bill rule to a rules predicate that answers has_perm("billing.view_bill", bill). What must the list page still do?

    Choose one answer.

    Show the answer: Question 11 of 11

    Answer: Filter its queryset with a condition that agrees with the predicate

    Whatever decides single objects, a list page needs a queryset filter that gives the same answer, and the access tests cover both.

Interview questions

Warm-up (fresher): What is the difference between a model permission and an object-level permission in Django?

A model permission applies to every object of a model: billing.change_bill lets its holder change any bill. Django creates add, change, delete and view for each model, plus those in Meta.permissions, and has_perm() checks them. An object-level permission applies to one object: “Asha may see this bill because she lives in its flat”. Django’s ModelBackend has none, and answers False for has_perm(perm, obj); you write object rules yourself, usually as a scoped queryset, or add a library such as django-guardian or rules.

Two longer questions of this module’s bank start from these rules: an access hole found in production, and the design of roles for a society with several towers.

Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.

  1. Mid to senior

    A resident reports that changing the number in /complaints/412/ shows other flats' complaints, with names and phone numbers. The portal is in production. What do you do in the first hour, in the first day, and to stop the same hole from appearing in another view?

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  2. Senior to staff

    Nivas grows to serve societies with several towers. Each tower has its own committee, which may see and mark the bills of its tower only, and a society treasurer sees every tower. Design the permissions, groups and querysets, and say what you test.

    Model answer, Pro. First step and follow-ups

Key takeaways

  • migrate creates add, change, delete and view permissions for every model, plus those of Meta.permissions; check them with user.has_perm('app_label.codename'), the string Permission.user_perm_str gives in Django 6.1.
  • Give permissions to groups that stand for roles, and people to groups; create the groups in code, after the permissions exist, as a post_migrate receiver does.
  • ModelBackend caches permissions on the user object, even through refresh_from_db(), and answers False for any object, so object rules are your own code.
  • @login_required with @permission_required(..., raise_exception=True) redirects visitors and gives signed-in users without the permission 403; it never checks which object.
  • Scope querysets to the user and look objects up inside them, so another resident’s object is a 404 like a missing one, and keep a test that requests another resident’s object.

Terms in this lesson

group
A named set of permissions in django.contrib.auth; a user has every permission of each group they belong to, so a group stands for a role such as a committee.
insecure direct object reference (IDOR)
A page or API that loads an object by an identifier taken from the request without checking that the person asking may see it, so changing the number shows other people's data.
permission cache
The set of permission names ModelBackend stores on a user object at its first check and reuses afterwards; only a user fetched again from the database sees permissions granted since.
post_migrate
The signal Django sends for each app after migrate has run; django.contrib.auth creates the models' permissions in a receiver of it, so code that needs those permissions listens to it too.

References

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.