Django / Reliable application workflows / Lesson 10
Permissions and object scope
Apply model permissions and resource-level access rules at the trusted view boundary before reading or changing protected data.
On this page
Lesson goals
- Name actor, operation, and resource
- Make a small ownership rule independently testable
- Prove denial without partial effects
Name actor, operation, and resource
Authorization decides whether an actor may perform an operation on a resource under the application’s current context. Authentication alone supplies no blanket permission. Django’s built-in model permission machinery and groups can express useful capabilities, but a global change permission is not automatically an ownership rule for every individual record. Define tenant membership, authorship, publication state, and privileged roles explicitly where they matter. Keep the rule in one reviewable place and enforce it on direct requests, not only through hidden controls. A navigation item can guide a user but cannot stop a forged request to the underlying endpoint.
Understand the selected backend’s guarantees
Django exposes object-aware permission interfaces, but the default ModelBackend does not implement a complete row-level object-permission system. Passing an object to a permission call does not magically create the application rule. A custom backend or reviewed package can provide additional behavior, and privileged shortcuts must be considered under that actual setup. Use the configured user model and avoid hard-coded role assumptions in reusable components. Decide whether a denied resource should produce a forbidden response or be concealed as missing under the product’s disclosure policy. Test that policy consistently rather than revealing existence through an accidental branch difference.
Make a small ownership rule independently testable
The first complete program defines a pure synthetic policy for one operation: an authenticated actor may edit a course only when the owner id matches. Dataclasses are used so the rule can be checked without a user table or session. The result is not an assertion that Django’s default backend enforces this rule automatically. A real view must invoke the policy or enforce an equivalent scoped lookup under its trusted identity. Include an anonymous actor and another authenticated owner in tests, since an implementation that merely checks is_authenticated would otherwise look correct on the happy path.
from django.conf import settings
settings.configure(
SECRET_KEY="synthetic-course-key",
USE_I18N=False, USE_TZ=True,
INSTALLED_APPS=[],
DATABASES={"default": {"ENGINE": "django.db.backends.sqlite3", "NAME": ":memory:"}},
DEFAULT_AUTO_FIELD="django.db.models.AutoField",
ALLOWED_HOSTS=["testserver"],
)
import django
django.setup()
from dataclasses import dataclass
@dataclass(frozen=True)
class Actor:
id: int | None
is_authenticated: bool
@dataclass(frozen=True)
class CourseRecord:
owner_id: int
def can_edit(actor, course):
return actor is not None and actor.is_authenticated and actor.id == course.owner_id
course = CourseRecord(owner_id=7)
print(can_edit(Actor(7, True), course))
print(can_edit(Actor(9, True), course))
print(can_edit(Actor(None, False), course))
True
False
False
This pure local rule illustrates an explicit ownership policy. It is not installed into Django automatically by defining the function.
Scope the instance before binding a write form
The second fragment assumes the catalog Course ownership model and CourseForm from the prior lesson, normal auth/session middleware, named routes, and CSRF protection. It obtains the course only from the current user’s queryset before binding submitted fields. The chosen policy intentionally returns a missing-resource outcome for another owner’s id, avoiding a global lookup followed by an accidental disclosure. Form acceptance then updates only the declared fields. This is an application fragment, not an executed database operation. If ownership can change concurrently and the domain requires a stronger invariant at commit, coordinate that within the appropriate transaction rather than relying on a stale earlier decision.
from django.contrib.auth.decorators import login_required
from django.shortcuts import get_object_or_404, redirect, render
from django.views.decorators.http import require_POST
from .forms import CourseForm
from .models import Course
@login_required
@require_POST
def update_course(request, course_id):
course = get_object_or_404(Course.objects.filter(owner=request.user), pk=course_id)
form = CourseForm(request.POST, instance=course)
if not form.is_valid():
return render(request, "catalog/course_form.html", {"form": form}, status=400)
course = form.save()
return redirect("course-detail", course_id=course.pk)
In the declared isolated app: non-owner id is not found; valid owner input updates approved fields only.
The declared product policy conceals other owners’ records through the scoped lookup. The form excludes owner and the endpoint retains normal CSRF protection.
Prove denial without partial effects
Test anonymous access, the valid owner, a different authenticated user, a missing record, and every actual privileged role. Assert both the response and preserved data after rejection, because a controller can write first and then return a forbidden response. A list endpoint also needs a visibility scope; allowing the listing operation does not authorize every row or nested field. Keep exports, related objects, and error payloads under the same review. Do not “fix” permission failures by making every account staff or superuser. Repair the specific rule, backend, or scope and verify that important negative boundaries remain intact.
Common errors and fixes
A global change permission is assumed to enforce ownership
- Why it happens
- Model capability and row-level policy were conflated.
- How to fix it
- Implement the actual object scope or supported permission backend.
- Prevent it next time
- Test another authenticated owner explicitly.
A forbidden response still leaves changed data
- Why it happens
- Authorization occurred after mutation.
- How to fix it
- Check access before binding and applying the write.
- Prevent it next time
- Assert unchanged state on denial.
A private export leaks related data
- Why it happens
- Only the main page’s visibility rule was reviewed.
- How to fix it
- Scope exported records and nested fields too.
- Prevent it next time
- Include exports and errors in access tests.
Check your understanding
1. Why is hiding an Edit button insufficient to protect the update endpoint?
Show worked solution
A caller can submit the endpoint directly without using the page. Enforce authentication and the object operation on the server, then validate and write approved fields. Keep the hidden button as helpful presentation, not as the authority deciding access.
2. Which assertions distinguish a secure denial from a write-then-deny bug?
Show worked solution
Assert the documented denied or missing response and reload the protected record to compare its original values and ownership. Also test a legitimate owner update so an always-reject implementation cannot pass the complete suite. Both positive and negative outcomes belong to the policy contract.
Key takeaways
- Permission capabilities and object scope are different.
- Enforce trusted access before mutation.
- Test both allowed behavior and preserved denial boundaries.