RBAC
Role-based access control, built on top of Django's own Group/Permission
system rather than a new one â no new tables, no new concepts a Django developer doesn't
already know. It shows up in the normal Django admin "Groups" machinery and works with
user.has_perm(...) like anything else.
Fails closed. An action with no rule declared is denied to everyone â
never "allowed to everyone." Declaring permissions at all means you're opting
into an allow-list, not a deny-list.
The permissions map
permissions maps an action to a rule. DRF's six actions collapse into four keys:
| Key | DRF actions |
|---|---|
view | list, retrieve |
create | create |
update | update, partial_update |
delete | destroy |
Each rule is one of three kinds:
| Rule | Meaning |
|---|---|
"public" | Always allowed, no login needed |
"authenticated" | Allowed if logged in â no specific permission required |
"app_label.codename" | Allowed only if request.user.has_perm(...) is true |
class ProductResource(Resource):
...
permissions = {
"view": "public",
"create": "products.add_product",
"update": "products.change_product",
"delete": "products.delete_product",
}
"products.add_product" needs no setup â Django auto-creates four permissions
per model the moment it's migrated: add_<model>, change_<model>,
delete_<model>, view_<model>, namespaced under the app label.
Roles = Django Groups
django_anvil.rbac.models.Role is a proxy model for Django's own
Group â Role.objects.create(...) writes to the same
auth_group table Django admin already manages. The only actually-custom piece is
.grant(), a convenience wrapper around looking up real Permission
objects by codename:
from django_anvil.rbac.models import Role
manager_role = Role.objects.create(name="Manager")
manager_role.grant("products.add_product", "products.change_product")
alice.groups.add(manager_role) # plain Django, User.groups is unmodified
You could delete django_anvil.rbac entirely and get the identical result with
Group/Permission directly â Anvil's version is just shorter to type.
Fails closed by default
class ResourcePermission(BasePermission):
def has_permission(self, request, view):
rule = view.permission_map.get(ACTION_KEYS.get(view.action))
if rule == "public":
return True
if rule == "authenticated":
return request.user.is_authenticated
if rule:
return request.user.is_authenticated and request.user.has_perm(rule)
# No rule declared for this action: fail closed, not open.
return False
A resource that only declares view and create leaves
update/delete unreachable by anyone â on purpose, so a forgotten key
can't accidentally become an open door.
Auto-generated permission tests
Any protected action gets a test_<model>_permissions.py file for free,
using real HTTP requests against the generated API â not mocks:
def test_anonymous_denied_on_create(self, api_client):
response = api_client.post(reverse("products-list"), payload, format="json")
assert response.status_code in (status.HTTP_401_UNAUTHORIZED, status.HTTP_403_FORBIDDEN)
def test_granted_user_can_create(self, api_client):
role = Role.objects.create(name="Product create role")
role.grant("products.add_product")
user.groups.add(role)
api_client.force_authenticate(user)
response = api_client.post(reverse("products-list"), payload, format="json")
assert response.status_code not in (status.HTTP_401_UNAUTHORIZED, status.HTTP_403_FORBIDDEN)
anvil doctor flags a permission codename that doesn't match any real
permission on the model â since RBAC fails closed, a typo there silently locks
everyone out of that action with no error to point at the cause. See
Audit & doctor.