Django REST Framework permissions work, but they show their limits as soon as access rules get moderately complex. Multiple roles, objects belonging to a specific user, custom actions on a ViewSet: you end up with has_permission and has_object_permission classes mixing heterogeneous checks, hard to read and even harder to test.
rest_access_policy (package djangorestframework-access-policy) takes a different approach: declare access rules as statements, similar to AWS IAM policies. The result is readable at a glance, testable independently of the ViewSet, and extensible without rewriting the entire class.
The Problem with Classic DRF Permissions
Take a common case: a blog article API. The rules look simple on the surface.
- Everyone can read.
- Authenticated users can create.
- An author can modify or delete their own articles.
- Admins can do everything.
With standard DRF, it looks like this:
class ArticlePermission(BasePermission):
def has_permission(self, request, view):
if request.method in SAFE_METHODS:
return True
if view.action == "create":
return request.user.is_authenticated
return request.user.is_authenticated
def has_object_permission(self, request, view, obj):
if request.method in SAFE_METHODS:
return True
if request.user.is_staff:
return True
return obj.author == request.user
This code is not bad for two rules. But it forces you to juggle between has_permission (before the object exists) and has_object_permission (after fetch), using SAFE_METHODS to distinguish reads from writes. Adding a third role or a custom action means modifying two methods, with a real risk of oversight.
The Declarative Approach of rest_access_policy
rest_access_policy inverts the logic: you don’t code behavior, you declare rules.
pip install djangorestframework-access-policy
Each rule is a statement with four keys:
| Key | What it defines |
|---|---|
principal | Who is affected (*, "authenticated", "admin", ["group:name"]) |
action | Which ViewSet action (list, create, retrieve, update, …) |
effect | allow or deny |
condition | Name of a method on the Policy (optional) |
The Policy evaluates all statements and collects those whose principal and action match the request. Access is granted if at least one allow statement matches and no deny statement matches. If no statement matches, access is denied by default.
Full Example: Blog Article Policy
from rest_access_policy import AccessPolicy
class ArticleAccessPolicy(AccessPolicy):
statements = [
{
"action": ["list", "retrieve"],
"principal": "*",
"effect": "allow",
},
{
"action": ["create"],
"principal": "authenticated",
"effect": "allow",
},
{
"action": ["update", "partial_update", "destroy"],
"principal": "authenticated",
"effect": "allow",
"condition": "is_author",
},
{
"action": "*",
"principal": ["group:admins"],
"effect": "allow",
},
]
def is_author(self, request, view, action) -> bool:
article = view.get_object()
return article.author == request.user
The ViewSet uses the Policy like any DRF permission:
class ArticleViewSet(viewsets.ModelViewSet):
queryset = Article.objects.select_related("author")
serializer_class = ArticleSerializer
permission_classes = [ArticleAccessPolicy]
Four statements are enough to cover all cases. You read the rules top to bottom like a functional specification, not defensive code.
Reusable Conditions
In a real project, multiple resources share the same checks: is_owner, is_active_user, is_published. Rather than duplicating methods in every Policy, extract them into a mixin.
class BaseConditions:
def is_owner(self, request, view, action) -> bool:
obj = view.get_object()
return obj.owner == request.user
def is_active_subscription(self, request, view, action) -> bool:
return request.user.subscription.is_active
class ArticleAccessPolicy(BaseConditions, AccessPolicy):
statements = [
{
"action": ["update", "destroy"],
"principal": "authenticated",
"effect": "allow",
"condition": "is_owner",
},
...
]
class CommentAccessPolicy(BaseConditions, AccessPolicy):
statements = [
{
"action": ["create"],
"principal": "authenticated",
"effect": "allow",
"condition": "is_active_subscription",
},
...
]
Python resolves is_owner through MRO order. The Policy inherits conditions, the code is defined only once.
Combining Multiple Conditions
By default, when the condition key contains a single string, the condition must return True for the statement to apply. You can also pass a list: all conditions must then be true (AND logic).
{
"action": ["publish"],
"principal": "authenticated",
"effect": "allow",
"condition": ["is_author", "is_active_subscription"],
}
For OR logic, rest_access_policy supports condition_expression, which accepts a boolean expression as a string:
{
"action": ["update"],
"principal": "authenticated",
"effect": "allow",
"condition_expression": ["is_author or is_editor"],
}
is_author and is_editor are still methods on the Policy (or an inherited mixin). The library evaluates the expression by calling each method and substituting the result. This is more readable than writing a single is_author_or_editor method that duplicates the logic by hand.
Filtering Querysets with scope_queryset
There is a DRF behavior that many developers discover too late: has_object_permission is only called when an object has already been fetched from the database. This applies only to actions that work on an object identified by its ID: retrieve, update, partial_update, and destroy.
The list action works differently. DRF returns the queryset as-is, without iterating over objects to call has_object_permission one by one. That would be too costly in queries, and it is simply not DRF’s responsibility.
The concrete consequence:
GET /articles/1/ → DRF fetches the object → checks has_object_permission → 403 if denied
GET /articles/ → DRF returns the raw queryset → has_object_permission never called
Without precautions, a user who cannot access /articles/1/ still sees that article in GET /articles/. They know the object exists, they can see its title or metadata depending on the serializer, they just get a 403 if they try to access it directly. That is an information leak, not a security boundary.
rest_access_policy provides scope_queryset to close this gap. This is where you express, at the queryset level, the same rules declared in the statements. The Policy handles “can they perform this action?”, scope_queryset handles “what can they see in a list?”. Both must be consistent.
class ArticleAccessPolicy(AccessPolicy):
statements = [...]
@classmethod
def scope_queryset(cls, request, queryset):
# Admins see everything, including drafts
if request.user.groups.filter(name="admins").exists():
return queryset
# Others only see published articles
return queryset.filter(status="published")
In the ViewSet:
class ArticleViewSet(viewsets.ModelViewSet):
permission_classes = [ArticleAccessPolicy]
def get_queryset(self):
queryset = Article.objects.select_related("author")
return ArticleAccessPolicy.scope_queryset(self.request, queryset)
scope_queryset is deliberately a classmethod: it can be called from get_queryset without instantiating the policy. And because it translates to SQL, the filter happens in a single query, not in a loop over objects.
Tests
The advantage of declarative policies is that they are unit-testable without spinning up a view. rest_access_policy provides a _evaluate_statements method, but the most pragmatic approach remains testing via the DRF client.
from rest_framework.test import APITestCase
from django.contrib.auth import get_user_model
from django.contrib.auth.models import Group
User = get_user_model()
class ArticleAccessPolicyTest(APITestCase):
def setUp(self):
self.author = User.objects.create_user("author", password="pass")
self.other = User.objects.create_user("other", password="pass")
self.admin = User.objects.create_user("admin", password="pass")
admin_group = Group.objects.create(name="admins")
self.admin.groups.add(admin_group)
self.article = Article.objects.create(
title="Test", author=self.author, status="published"
)
def test_anonymous_can_read(self):
response = self.client.get(f"/api/articles/{self.article.pk}/")
self.assertEqual(response.status_code, 200)
def test_author_can_update_own_article(self):
self.client.force_authenticate(self.author)
response = self.client.patch(
f"/api/articles/{self.article.pk}/", {"title": "New title"}
)
self.assertEqual(response.status_code, 200)
def test_other_user_cannot_update(self):
self.client.force_authenticate(self.other)
response = self.client.patch(
f"/api/articles/{self.article.pk}/", {"title": "Hack"}
)
self.assertEqual(response.status_code, 403)
def test_admin_can_delete_any_article(self):
self.client.force_authenticate(self.admin)
response = self.client.delete(f"/api/articles/{self.article.pk}/")
self.assertEqual(response.status_code, 204)
Each test targets a precise rule. When a rule changes in the Policy, the corresponding test breaks, not a catch-all method.
Common Pitfalls
scope_queryset not implemented. Without it, list returns objects the user has no right to see, even if retrieve blocks them. Both must be consistent.
Condition called on list. If a condition calls view.get_object() (like is_author), it triggers an extra query for each object in list mode, creating N+1 queries. The solution: build statements so these conditions only apply to single-object actions (retrieve, update, partial_update, destroy).
AccessPolicy missing from permission_classes. If the Policy is inherited but not referenced in permission_classes, DRF applies no restriction. A silent error that won’t surface in tests if fixtures don’t cover denial cases.
Multiple policies in permission_classes. DRF combines permission classes with AND logic. Stacking ArticleAccessPolicy and IsAuthenticated will deny unauthenticated users even for list actions marked allow for *. One Policy per ViewSet, authentication checks go inside via principal.
What rest_access_policy Is Not
The library handles authorization at the API level: who can do what on which endpoint. It does not replace Django model-level permissions (django-guardian for multi-user row-level access), nor an audit trail layer, nor a full RBAC system with role hierarchy.
For a project with simple to moderate access rules on DRF APIs, it is exactly the right tool: less defensive code, more readable rules, targeted tests.
