Il problema che Django non risolve da solo

Django include un sistema di permessi robusto. Puoi definire permessi a livello di modello — add_progetto, change_progetto, delete_progetto, view_progetto — e assegnarli a utenti o gruppi. Per la maggior parte dei casi è più che sufficiente.

Il problema me lo sono trovato davanti quando ho dovuto gestire un permesso che non riguardava "può modificare i progetti" in generale, ma "può modificare solo i progetti del suo ente". O, per le commissioni di selezione, "può vedere solo le candidature a cui è stato assegnato". Una differenza piccola sulla carta, enorme nel codice.

Django, nativamente, questo non lo sa fare. L'alternativa è filtrare ogni query a mano — Progetto.objects.filter(ente=request.user.responsabile.ente) — e funziona, fino a quando non dimentichi di farlo in una view, o un serializzatore DRF lo salta, o aggiungi un endpoint nuovo senza ricordarti del filtro. Basta una distrazione per esporre dati che nessuno doveva vedere.

Come abbiamo visto nell'articolo su OWASP Top 10 applicato a Django, il Broken Access Control è la vulnerabilità più diffusa nelle applicazioni web — e nasce quasi sempre da esattamente questo: permessi non verificati a livello di oggetto.

django-guardian risolve il problema alla radice.

django-guardian: permessi granulari in Django, spiegati con un caso reale

Cos'è django-guardian e come funziona

django-guardian è una libreria che estende il sistema di autenticazione di Django aggiungendo un backend per i permessi a livello di oggetto. Invece di chiedere "questo utente ha il permesso view_progetto?" puoi chiedere "questo utente ha il permesso view_progetto su questo specifico Progetto?".

Internamente, django-guardian crea due tabelle nel database — UserObjectPermission e GroupObjectPermission — che associano un utente o un gruppo a un permesso su una specifica istanza di un modello. Usa i ContentTypes di Django per riferirsi ai modelli in modo generico.

Installazione:

pip install django-guardian

settings.py:

INSTALLED_APPS = [
    "guardian",
]

AUTHENTICATION_BACKENDS = [
    "django.contrib.auth.backends.ModelBackend",
    "guardian.backends.ObjectPermissionBackend",
]

ANONYMOUS_USER_NAME = None

ANONYMOUS_USER_NAME = None disabilita il supporto per gli utenti anonimi — nella maggior parte delle applicazioni aziendali non serve e aggiunge complessità inutile.

Dopo migrate, le tabelle di django-guardian vengono create automaticamente.

Il caso reale: Skara e i permessi tra enti

Skara è un gestionale per la gestione di progetti di servizio civile. Ogni progetto appartiene a un ente — un'organizzazione che ha aderito al programma. I responsabili di un ente devono poter vedere e modificare solo i progetti del loro ente, non quelli degli altri.

Con il sistema di permessi standard di Django, avrei dovuto filtrare ogni queryset a mano:

def get_queryset(self):
    return Progetto.objects.filter(ente=self.request.user.responsabile.ente)

Ci ho provato per un po'. Poi ho iniziato ad aggiungere view nuove e mi sono reso conto che stavo riscrivendo lo stesso filtro ovunque, con il rischio concreto di scordarmelo da qualche parte — una viewset DRF generica, un endpoint aggiunto in fretta prima di una demo, qualcosa del genere.

Con django-guardian il permesso vive direttamente sull'oggetto, e Django lo verifica da solo quando uso i meccanismi standard — niente da ricordare ogni volta.

Assegnare il permesso quando si crea un progetto:

from guardian.shortcuts import assign_perm
from django.db.models.signals import post_save
from django.dispatch import receiver
from myapp.models import Progetto

@receiver(post_save, sender=Progetto)
def assegna_permessi_progetto(sender, instance, created, **kwargs):
    if created:
        ente = instance.ente
        for responsabile in ente.responsabili.all():
            assign_perm("view_progetto", responsabile.user, instance)
            assign_perm("change_progetto", responsabile.user, instance)

Ogni volta che viene creato un nuovo progetto, il signal assegna automaticamente i permessi a tutti i responsabili dell'ente. Se aggiungo un nuovo responsabile all'ente in un secondo momento, devo assegnare i permessi anche sui progetti esistenti — lo gestisco con un signal separato sul modello Responsabile.

Verificare il permesso in una view:

from guardian.shortcuts import get_objects_for_user

class ProgettoViewSet(viewsets.ModelViewSet):
    serializer_class = ProgettoSerializer

    def get_queryset(self):
        return get_objects_for_user(
            self.request.user,
            "myapp.view_progetto",
            klass=Progetto
        )

get_objects_for_user restituisce tutti gli oggetti su cui l'utente ha quel permesso specifico. Non devo filtrare per ente — django-guardian lo sa già. Se domani un responsabile cambia ente, revoco i vecchi permessi e assegno i nuovi, e automaticamente vede solo i progetti giusti.

assign_perm e remove_perm: le funzioni principali

Le due funzioni che usi più spesso sono assign_perm e remove_perm.

Assegnare un permesso a un utente su un oggetto:

from guardian.shortcuts import assign_perm

assign_perm("view_progetto", user, progetto)
assign_perm("change_progetto", user, progetto)

Assegnare un permesso a un gruppo su un oggetto:

from guardian.shortcuts import assign_perm
from django.contrib.auth.models import Group

commissione = Group.objects.get(name="Commissione Alpha")
assign_perm("view_candidatura", commissione, candidatura)

In Skara uso i gruppi per le commissioni di selezione — ogni commissione è un Group Django, e i membri hanno accesso solo alle candidature della loro commissione.

Revocare un permesso:

from guardian.shortcuts import remove_perm

remove_perm("change_progetto", user, progetto)

Verificare se un utente ha un permesso su un oggetto:

user.has_perm("myapp.view_progetto", progetto)

Questo funziona attraverso il backend di autenticazione configurato in settings.py — Django chiama automaticamente ObjectPermissionBackend di guardian quando viene passato un oggetto come secondo argomento.

Integrazione con Django REST Framework

Con DRF, puoi usare DjangoObjectPermissions come permission class — verifica automaticamente i permessi sull'oggetto quando viene chiamato get_object():

from rest_framework.permissions import DjangoObjectPermissions

class ProgettoViewSet(viewsets.ModelViewSet):
    permission_classes = [IsAuthenticated, DjangoObjectPermissions]
    serializer_class = ProgettoSerializer

    def get_queryset(self):
        return get_objects_for_user(
            self.request.user,
            "myapp.view_progetto",
            klass=Progetto
        )

DjangoObjectPermissions gestisce automaticamente il mapping tra i metodi HTTP e i permessi Django:

  • GET, HEAD, OPTIONS → view_
  • POST → add_
  • PUT, PATCH → change_
  • DELETE → delete_

Per i permessi di visualizzazione (GET) su oggetti singoli, aggiungi anche djangorestframework-guardian:

pip install djangorestframework-guardian
from rest_framework_guardian.filters import ObjectPermissionsFilter

class ProgettoViewSet(viewsets.ModelViewSet):
    filter_backends = [ObjectPermissionsFilter]

ObjectPermissionsFilter filtra automaticamente il queryset restituendo solo gli oggetti su cui l'utente ha il permesso view_ — equivalente a get_objects_for_user ma integrato nel meccanismo di filtering di DRF.

Testing dei permessi con django-guardian

Nei test, django-guardian funziona normalmente senza configurazione aggiuntiva. L'unica accortezza è usare il database nei test che verificano i permessi:

import pytest
from guardian.shortcuts import assign_perm
from myapp.tests.factories import ProgettoFactory, UserFactory

@pytest.mark.django_db
def test_responsabile_vede_solo_propri_progetti(client):
    user_a = UserFactory()
    user_b = UserFactory()
    progetto_a = ProgettoFactory()
    progetto_b = ProgettoFactory()

    assign_perm("view_progetto", user_a, progetto_a)
    assign_perm("view_progetto", user_b, progetto_b)

    client.force_login(user_a)
    response = client.get("/api/progetti/")

    assert response.status_code == 200
    ids = [p["id"] for p in response.json()]
    assert str(progetto_a.id) in ids
    assert str(progetto_b.id) not in ids

Questo test verifica esattamente il requisito di business: user_a vede solo i progetti su cui ha il permesso, non quelli degli altri.

Performance: il punto critico

django-guardian ha un costo in query. Ogni chiamata a get_objects_for_user genera query sulle tabelle UserObjectPermission e GroupObjectPermission. Su modelli con molte istanze e molti utenti, questo può diventare un problema.

Le ottimizzazioni principali:

Usare permessi diretti invece di generic foreign key — django-guardian supporta modelli custom per i permessi che usano ForeignKey dirette invece di GenericForeignKey, molto più veloci:

from guardian.models import UserObjectPermissionBase

class ProgettoUserPermission(UserObjectPermissionBase):
    content_object = models.ForeignKey(
        Progetto,
        on_delete=models.CASCADE
    )

    class Meta(UserObjectPermissionBase.Meta):
        abstract = False

Prefetch dei permessi — per evitare N+1 nelle view che mostrano liste con informazioni sui permessi:

from guardian.core import ObjectPermissionChecker

checker = ObjectPermissionChecker(request.user)
checker.prefetch_perms(queryset)

for obj in queryset:
    if checker.has_perm("change_progetto", obj):
        ...

In Skara uso i modelli custom per i permessi su tutti i modelli principali — la differenza di performance su dataset reali è significativa.

Quando usare django-guardian e quando no

Lo installerei di nuovo se:

  • i permessi cambiano istanza per istanza dello stesso modello
  • il criterio di accesso non si riduce a un filtro semplice su un campo
  • serve che il permesso sia persistente e modificabile senza toccare il codice
  • utenti diversi devono vedere sottoinsiemi diversi degli stessi dati

Lo eviterei se invece il criterio è sempre lo stesso — "l'utente vede solo i suoi oggetti" si risolve benissimo con owner=request.user, senza bisogno di una libreria in più. O se gli oggetti e gli utenti in gioco sono pochi: a quel punto l'overhead di guardian non si giustifica, e il sistema di permessi nativo di Django basta e avanza.

In Skara la scelta ha senso perché i permessi cambiano nel tempo — un responsabile passa a un altro ente, un membro lascia una commissione, un progetto viene trasferito ad altra competenza — e gestire tutto questo con filtri sparsi nelle view, sinceramente, non lo rifarei una seconda volta.