Engineering Notes

Note 04 / Mandantentrennung

Django: Datenzugriff an den Mandanten binden

Eine Dokument-ID ist keine Berechtigung. Mitgliedschaft prüfen, Abfragen eingrenzen und fremde IDs gezielt testen.

Das Problem: ein gültiges, fremdes Objekt

In einer gemeinsam genutzten Django-Anwendung kann derselbe Benutzer mehreren Mandanten angehören. Ein Detail-Endpunkt erhält eine Dokument-ID und einen ausgewählten Mandanten. Wenn er nur nach dem Primärschlüssel sucht, kann ein gültiger fremder Schlüssel zum falschen Dokument führen. Auch eine schwer zu erratende UUID ersetzt die Berechtigungsprüfung nicht.

Behandeln Sie den Mandanten aus URL, Cookie oder Formular zunächst als Eingabe. Die Datenbank muss bestätigen, dass der angemeldete Benutzer auf diesen Mandanten zugreifen darf. Erst danach beginnt die Dokumentabfrage.

Zwei Prüfungen an einer Stelle

Das Beispiel verwendet ein einfaches Berechtigungsmodell: Jede Mitgliedschaft erlaubt das Lesen aller Dokumente dieses Mandanten. Der Service prüft die Mitgliedschaft und sucht die Dokument-ID ausschließlich im eingegrenzten QuerySet. Eine fehlende Mitgliedschaft ergibt PermissionDenied; ein nicht sichtbares Dokument ergibt Http404. Die View darf diesen Service erst nach der Authentifizierung aufrufen.

Python · Modelle und Leseservice
from django.conf import settings
from django.core.exceptions import PermissionDenied
from django.db import models
from django.shortcuts import get_object_or_404

class Tenant(models.Model):
    name = models.CharField(max_length=120)

    class Meta:
        app_label = "notes_demo"

class Membership(models.Model):
    tenant = models.ForeignKey(Tenant, on_delete=models.CASCADE)
    user = models.ForeignKey(settings.AUTH_USER_MODEL,
                             on_delete=models.CASCADE)

    class Meta:
        app_label = "notes_demo"
        constraints = [models.UniqueConstraint(
            fields=["tenant", "user"], name="demo_member_unique")]

class Document(models.Model):
    tenant = models.ForeignKey(Tenant, on_delete=models.CASCADE)
    title = models.CharField(max_length=240)

    class Meta:
        app_label = "notes_demo"


def read_document(user, tenant_id, document_id):
    # Mandantenmitgliedschaft aus der Datenbank prüfen.
    if not user.is_authenticated or not Membership.objects.filter(
        user=user, tenant_id=tenant_id
    ).exists():
        raise PermissionDenied
    # Dokument-ID innerhalb des erlaubten Mandanten suchen.
    return get_object_or_404(
        Document.objects.filter(tenant_id=tenant_id),
        pk=document_id,
    )

Den verbotenen Zugriff testen

Der lokale Test legt zwei synthetische Mandanten an. Ein Benutzer gehört nur zu einem davon. Der Test liest das erlaubte Dokument, fordert eine fremde Dokument-ID im erlaubten Mandanten an und versucht anschließend, den fremden Mandanten auszuwählen. Die beiden verbotenen Wege müssen scheitern. Ein anonymer Benutzer wird ebenfalls abgewiesen.

Ausgeführt mit Python 3.12 und Django 5.2 auf einer lokalen SQLite-Datenbank. Das prüft den ORM-Zugriff dieses Beispiels. PostgreSQL-spezifische Sperren, Row-Level Security und Nebenläufigkeit sind damit nicht geprüft.

Grenzen: der Service ist nicht die ganze Anwendung

Das ungefilterte Document.objects bleibt erreichbar. Admin, Hintergrundjobs, Exporte und neue Views können den Service umgehen. Verankern Sie deshalb dieselbe Regel in allen Zugriffspfaden und suchen Sie beim Review nach ungefilterten Objektabfragen. Cache-Schlüssel müssen den Mandanten enthalten; ein Cache mit nur der Dokument-ID kann die Prüfung unterlaufen.

Für Schreibzugriffe reicht dieses Lesebeispiel nicht: Auch referenzierte Fremdschlüssel und Rollen sind zu prüfen. Eine parallel entzogene Mitgliedschaft erfordert eine bewusst gewählte Transaktionsstrategie. PostgreSQL Row-Level Security kann eine zusätzliche Schutzschicht sein; Tabellenbesitzer, Rollen mit BYPASSRLS und der Kontext von Connection-Pools müssen dabei berücksichtigt werden.