The recording for this lesson has not been published yet.
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:
is_active,
is_staff (may open the admin), is_superuser (passes every
permission check unconditionally).
Every time you run migrate, django.contrib.auth creates four
permissions per model, using the content type of that model:
| Codename | Full string | Meaning |
|---|---|---|
add_post | blog.add_post | may create posts |
change_post | blog.change_post | may edit posts |
delete_post | blog.delete_post | may delete posts |
view_post | blog.view_post | may 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.
The first has_perm() call loads every permission of the user and caches it
on the instance. If you grant a permission and check it in the same request, refresh
first: user = User.objects.get(pk=user.pk). This is the number one reason
a permission test "does not work" in the shell.
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
An "editor" who is not is_staff cannot even open /admin/. And
an is_superuser account passes every check, so it is useless for testing
your rules - always test with a normal account.
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 %}
Anyone can type the URL. The template check is a courtesy to the user; the view check is the security boundary. Every protected URL needs the check in the view, always.
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)
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.
| Rule | Use |
|---|---|
| "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)
get_all_permissions() for a fresh user, then add them
to a group and print it again (remember to reload the user).publish_post permission to Post and create
authors and editors groups in a data migration.post_new with blog.add_post and
raise_exception=True; confirm a user without it sees 403, not a login
loop.AuthorRequiredMixin and use it for editing and deleting. Verify that
another user gets 403 while an editor gets through.