Course outline

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.

About 4 min readContent reviewed
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.

python Isolated executable example
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))
Expected output
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.

python Application-context fragment
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)
Expected output
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.

Official references