django-anvil v0.1
🔍Search docs...⌘K
Core concepts

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:

KeyDRF actions
viewlist, retrieve
createcreate
updateupdate, partial_update
deletedestroy

Each rule is one of three kinds:

RuleMeaning
"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.