Object-level access for sensitive records
Django's built-in permissions are model-level: "may this user edit patients?" — yes or no, for all of them. But real applications, especially ones holding sensitive data like a clinic's, need object-level rules: a patient may view their own reports but not anyone else's; a doctor sees their patients, not the whole register. Django does not give you object-level permissions out of the box, and knowing how to add them correctly — without opening a data leak — is essential for any app with private records. This lesson is that gap and how to fill it.
The gap: model-level is not enough
user.has_perm("reports.view_testreport") answers "can this user view reports?" — a single yes/no for the
whole model. It cannot express "this user can view report #42 because it is theirs, but not report #43".
For a clinic, that distinction is the entire point: everyone with the "view reports" permission being able
to see every patient's report is a serious privacy breach. Sensitive data needs per-object rules, and
that is a layer above Django's defaults.
The core technique: filter the queryset by the user
The most important object-level pattern is not a permission check at all — it is scoping the queryset to what the user is allowed to see, so forbidden objects never appear in the first place:
# a patient sees only their own reports
def my_reports(request):
reports = TestReport.objects.filter(patient__user=request.user)
return render(request, "reports/list.html", {"reports": reports})
# a doctor sees only their patients' appointments
def my_appointments(request):
appointments = Appointment.objects.filter(doctor=request.user.doctor)
return render(request, "appointments/list.html", {"appointments": appointments})
This is the safest approach because a user cannot access what is not in their queryset — there is no
report #43 in the list to leak, no way to stumble onto it. Filter by the user at the query, and the
object-level rule enforces itself. This is also where the custom-manager lesson pays off: a
TestReport.objects.visible_to(user) method centralises "what can this user see" in one place, and every
view uses it. Prefer scoping the queryset over fetching-then-checking wherever you can.
Checking access to a single object
When you must fetch one object (a detail page at /reports/42/), you cannot rely on the list filter — the
user typed the id. Check ownership after fetching, and return 404 (not 403) for objects they should not
see:
from django.shortcuts import get_object_or_404
def report_detail(request, report_id):
report = get_object_or_404(TestReport, id=report_id)
if report.patient.user != request.user and not request.user.is_staff:
raise Http404 # 404, not 403 — do not reveal the record exists
return render(request, "reports/detail.html", {"report": report})
Two subtleties that matter for sensitive data. First, combine the fetch and the ownership check — better
still, fold the ownership into the fetch itself: get_object_or_404(TestReport, id=report_id, patient__user=request.user) returns 404 if the report is not theirs, in one line. Second, return 404, not
403, for records a user should not know exist. A 403 ("forbidden") confirms the record is there; for a
medical report, even revealing that report #43 exists can be a leak. A 404 says nothing. Choosing 404 over
403 for private records is a deliberate privacy decision.
When you need real object-level permissions: django-guardian
Sometimes the rules are richer than "owns it" — arbitrary per-object grants, like "these three specific
doctors may access this specific patient's file". For that, the established library is django-guardian,
which adds true per-object permissions on top of Django's system:
from guardian.shortcuts import assign_perm, get_objects_for_user
assign_perm("view_testreport", doctor_user, specific_report) # grant on ONE object
get_objects_for_user(request.user, "reports.view_testreport") # the reports THIS user may view
Guardian stores per-object grants and lets you query "which objects can this user access". Reach for it when ownership-by-query is genuinely insufficient — complex sharing, delegated access, arbitrary grants. For most apps, though, queryset filtering by the user covers the need, and you should not add Guardian speculatively: it adds a table and queries per check, so use it only when the simple approach cannot express your rules.
The layered model to hold in your head
Put the whole authorisation picture together, because these layers combine:
- Authenticated?
@login_required— is anyone logged in at all. - Model-level permission?
has_perm("reports.view_testreport")— may this role view reports. - Object-level scope?
filter(patient__user=request.user)— which reports may this user see.
A clinic report view uses all three: you must be logged in, you must have the "view reports" permission, and you only ever see the reports that are yours (or, for staff, the ones their role covers). The mistake that causes real breaches is stopping at layer 2 — assuming "has the view permission" means "may see everything" — when sensitive data demands layer 3. Model-level says what kind of thing; object-level says which ones. For anything private, you need both.
Check your work
The gap. Built-in permissions are model-level ("view reports at all?"); they cannot express "view this report but not that one" — which sensitive data requires.
The core technique. Scope the queryset to the user (filter(patient__user=request.user)) so forbidden
objects never appear — the safest object-level rule, because there is nothing to leak; centralise it in a
manager method.
Single-object access. Fold ownership into the fetch (get_object_or_404(..., patient__user=request.user))
and return 404, not 403, for records a user should not know exist.
When to use django-guardian. For true per-object grants (arbitrary sharing, delegated access) that
ownership-by-query cannot express — not speculatively; it costs a table and per-check queries.
The three layers. Authenticated (login_required) → model permission (has_perm) → object scope
(queryset filter). Sensitive data needs all three; stopping at model-level is the classic breach.
Practice
- Write a
my_reportsview that filtersTestReportbyrequest.user; confirm one user cannot see another's reports because they are not in the queryset. - Write a
report_detailview that folds ownership intoget_object_or_404; confirm accessing someone else's report id returns 404, not the record. - Change that 404 to a 403 and reason about what a 403 leaks for a medical record; switch back to 404.
- Add a
visible_to(user)method on a custom manager and rewrite the views to use it; note the rule now lives in one place. - Combine all three layers on one view (
login_required+ permission + queryset scope) and test each failing independently. - Read
django-guardian'sassign_perm/get_objects_for_userand describe one Nidaan rule that would genuinely need it versus one that queryset filtering handles.
Official documentation
- Django — Limiting access to logged-in users and permissions — The built-in layers.
- Django —
get_object_or_404— Folding a filter into the fetch. - django-guardian documentation — True per-object permissions.
Next: Django's security defaults and what you must not disable.
Stuck on this lesson?
Being stuck is part of it — but being stuck alone for three days is not. Our internship programme pairs this curriculum with code review and one-to-one help from working developers, and it is free.
About the internship