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.
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:
"""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:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- 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.
- 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.
- 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.
- 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.
- 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.
- 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()
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:
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
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:
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 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
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:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- 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.
- 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.
- 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.
- 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.
- No row, so 404. No bill matches both conditions, so get_object_or_404() raises Http404 and the page answers 404 Not Found.
- 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:
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:
"""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
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:
Answer the questions above: the table then marks what fits your case and says why the rest does not.
| 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
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.
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
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.
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.
-
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?
-
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.
Key takeaways
migratecreates add, change, delete and view permissions for every model, plus those ofMeta.permissions; check them withuser.has_perm('app_label.codename'), the stringPermission.user_perm_strgives 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_migratereceiver does. ModelBackendcaches permissions on the user object, even throughrefresh_from_db(), and answersFalsefor any object, so object rules are your own code.@login_requiredwith@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
- Using the Django authentication system: permissions and authorization (Django Software Foundation)
- Customizing authentication: custom permissions (Django Software Foundation)
- django.contrib.auth reference: Permission (Django Software Foundation)
- Django 6.1 release notes (Django Software Foundation)
- Signals: post_migrate (Django Software Foundation)
- OWASP Top 10:2025, A01 Broken Access Control (OWASP Foundation)
- django-guardian documentation (django-guardian project)
- rules (django-rules project (GitHub))
Report a problem with this lesson
Kept only in this browser. Your Learn progress