Video coming soon

The recording for this lesson has not been published yet.

What we cover

  • Default add, change, delete and view permissions
  • Groups such as editors and moderators
  • permission_required and PermissionRequiredMixin
  • Object-level checks and django-guardian

Lesson notes

1. Two different questions

Lesson 4.2 answered "who are you?" - authentication. This lesson answers "what may you do?" - authorization. In your blog the difference is concrete: right now every logged-in user can edit every post, because @login_required only checks that somebody is signed in.

Django gives you three levels of answer, and you need all three:

  • Flags on the user - is_active, is_staff (may open the admin), is_superuser (passes every permission check unconditionally).
  • Model permissions - "may add posts", assigned directly or through a group. They are per model, not per row.
  • Object-level checks - "may edit this post because I wrote it". Django has no built-in storage for these, so you write the rule yourself.

2. The four permissions you already have

Every time you run migrate, django.contrib.auth creates four permissions per model, using the content type of that model:

CodenameFull stringMeaning
add_postblog.add_postmay create posts
change_postblog.change_postmay edit posts
delete_postblog.delete_postmay delete posts
view_postblog.view_postmay see posts in the admin

The string is always app_label.codename. Check it with has_perm:

>>> user.has_perm("blog.change_post")
False
>>> user.get_all_permissions()
{'blog.view_post'}

Add your own permissions in Meta.permissions when the four defaults are not enough:

# blog/models.py
class Post(models.Model):
    ...

    class Meta:
        permissions = [
            ("publish_post", "Can publish a post"),
            ("feature_post", "Can feature a post on the front page"),
        ]

That needs a migration (makemigrations blog), because permissions are rows in the auth_permission table.

3. Groups: editors and moderators

Assigning permissions to individual users does not scale - you will forget somebody. A Group is just a named bundle of permissions; a user gets the union of their own permissions and those of every group they belong to. Create the groups in a data migration so every environment has them:

# blog/migrations/0004_author_and_editor_groups.py
from django.db import migrations

GROUPS = {
    "authors": ["add_post", "change_post", "view_post"],
    "editors": ["add_post", "change_post", "delete_post", "view_post", "publish_post"],
}


def create_groups(apps, schema_editor):
    Group = apps.get_model("auth", "Group")
    Permission = apps.get_model("auth", "Permission")
    for name, codenames in GROUPS.items():
        group, _ = Group.objects.get_or_create(name=name)
        group.permissions.set(
            Permission.objects.filter(
                content_type__app_label="blog", codename__in=codenames
            )
        )


def delete_groups(apps, schema_editor):
    apps.get_model("auth", "Group").objects.filter(name__in=GROUPS).delete()


class Migration(migrations.Migration):
    dependencies = [("blog", "0003_post_permissions")]
    operations = [migrations.RunPython(create_groups, delete_groups)]

From then on, membership is one line - typically right after signup:

from django.contrib.auth.models import Group

authors = Group.objects.get(name="authors")
user.groups.add(authors)          # or user.groups.remove(authors)
user.user_permissions.add(perm)   # direct grant, use sparingly

4. Which check belongs where

request arrives at the view user.is_authenticated ? LoginRequiredMixin no redirect to login with ?next= yes has_perm("blog.change_post") ? PermissionRequiredMixin no 403 Forbidden PermissionDenied yes post.author == user ? UserPassesTestMixin no yes the view runs an editor may skip this last step: author OR has publish_post is a common combined rule

5. permission_required in views

For a function view, one decorator:

# blog/views.py
from django.contrib.auth.decorators import login_required, permission_required


@login_required
@permission_required("blog.add_post", raise_exception=True)
def post_new(request):
    ...

raise_exception=True matters. Without it, a logged-in user who lacks the permission is redirected to the login page - where they are already logged in, so the page looks broken. With it, they get a clean 403.

For a class-based view, the mixins, and the order is fixed:

from django.contrib.auth.mixins import LoginRequiredMixin, PermissionRequiredMixin
from django.views.generic import CreateView

from .models import Post


class PostCreateView(LoginRequiredMixin, PermissionRequiredMixin, CreateView):
    model = Post
    fields = ["title", "text"]
    permission_required = "blog.add_post"        # or a tuple for several
    raise_exception = True                        # 403 instead of a login redirect

    def form_valid(self, form):
        form.instance.author = self.request.user  # never trust a posted author field
        return super().form_valid(form)

And in templates, where perms is always available:

{% if perms.blog.add_post %}
  <a href="{% url 'blog:post_new' %}">Write a post</a>
{% endif %}

{% if post.author == user or perms.blog.publish_post %}
  <a href="{% url 'blog:post_edit' post.pk %}">Edit</a>
{% endif %}

6. Only the author may edit their own post

blog.change_post says nothing about which post. Model permissions cannot express that, so add a second, object-level rule with UserPassesTestMixin:

from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin
from django.views.generic import DeleteView, UpdateView


class AuthorRequiredMixin(LoginRequiredMixin, UserPassesTestMixin):
    """Allow the author of the object, or anyone who can publish."""

    raise_exception = True

    def test_func(self):
        post = self.get_object()
        return post.author == self.request.user or self.request.user.has_perm(
            "blog.publish_post"
        )


class PostUpdateView(AuthorRequiredMixin, UpdateView):
    model = Post
    fields = ["title", "text"]


class PostDeleteView(AuthorRequiredMixin, DeleteView):
    model = Post
    success_url = reverse_lazy("blog:post_list")

test_func runs before the view body, for GET and POST alike, and returning False raises PermissionDenied. The function-view equivalent is a narrowed queryset, which is often even better because it cannot be forgotten:

@login_required
def post_edit(request, pk):
    post = get_object_or_404(Post, pk=pk, author=request.user)   # 404, not 403
    ...

Filtering by author=request.user answers 404 instead of 403 for somebody else's post. That leaks less information - a stranger cannot even learn that the post exists - so prefer it for anything sensitive.

The same idea belongs in the admin, where staff should only see their own drafts:

# blog/admin.py
@admin.register(Post)
class PostAdmin(admin.ModelAdmin):
    def get_queryset(self, request):
        qs = super().get_queryset(request)
        if request.user.is_superuser:
            return qs
        return qs.filter(author=request.user)

7. When you need real per-object permissions

An author-only rule is a one-line comparison. But some projects need to store who may touch which row - "Bob may edit this one post", "this group moderates that category". Writing that yourself means a table of grants plus your own lookup code; the established package is django-guardian.

pip install django-guardian
# mysite/settings.py
INSTALLED_APPS += ["guardian"]
AUTHENTICATION_BACKENDS = [
    "django.contrib.auth.backends.ModelBackend",
    "guardian.backends.ObjectPermissionBackend",
]
from guardian.shortcuts import assign_perm, get_objects_for_user

assign_perm("blog.change_post", bob, post)      # a grant on one row
bob.has_perm("blog.change_post", post)          # True
get_objects_for_user(bob, "blog.change_post")   # queryset of the rows he may edit

Note the second argument to has_perm: Django's API has always accepted an object, the default ModelBackend simply ignores it. Guardian adds a backend that does not.

RuleUse
"logged-in users may comment"LoginRequiredMixin
"editors may delete any post"group + PermissionRequiredMixin
"authors may edit their own posts"UserPassesTestMixin or a filtered queryset
"these three people may edit this one post"django-guardian, or your own model field

Whatever you build, the tests are short and they are the only proof you have:

# blog/tests.py
class PostEditPermissionTests(TestCase):
    def setUp(self):
        User = get_user_model()
        self.ada = User.objects.create_user("ada", password="pw-for-tests-1")
        self.bob = User.objects.create_user("bob", password="pw-for-tests-2")
        self.post = Post.objects.create(author=self.ada, title="Hi", text="...")

    def test_stranger_cannot_edit(self):
        self.client.force_login(self.bob)
        response = self.client.get(reverse("blog:post_edit", args=[self.post.pk]))
        self.assertEqual(response.status_code, 403)

    def test_author_can_edit(self):
        self.client.force_login(self.ada)
        response = self.client.get(reverse("blog:post_edit", args=[self.post.pk]))
        self.assertEqual(response.status_code, 200)

8. Exercise

  1. In the shell, print get_all_permissions() for a fresh user, then add them to a group and print it again (remember to reload the user).
  2. Add a publish_post permission to Post and create authors and editors groups in a data migration.
  3. Protect post_new with blog.add_post and raise_exception=True; confirm a user without it sees 403, not a login loop.
  4. Write AuthorRequiredMixin and use it for editing and deleting. Verify that another user gets 403 while an editor gets through.
  5. Hide the Edit link in the template for people who cannot use it, then prove with the two tests above that the view is protected even when the link is hidden.

Further reading

After this lesson you will

  • Use built-in model permissions in views and templates
  • Allow only the author to edit their own post