Ich gebe zu, Konfigurierbare API klingt erst einmal etwas ungewohnt. Tatsächlich meine ich damit aber genau das, was da steht. Das Ziel dieses Posts ist es zu zeigen, wie man eine HTTP API mit Django so erstellen kann, dass man als Anwender oder als Administrator die Ausgabe der Schnittstelle konfigurieren kann, ohne dabei die Applikation neu zu starten.

Eine Frage, die jetzt vielleicht im Raum steht ist: “Wozu das?”.

Der konkrete Use-Case

In meinem Fall ist es so gewesen, dass ich für ein Projekt einen OAuth2 Provider gebaut habe. Ein Client C kann sich also registrieren und erhält eine ClientID und ein ClientSecret. Mit dessen Implementierung kann Client C seinen Usern die Möglichkeit geben seine Applikation zu nutzen, ohne, dass diese sich bei ihm extra “registrieren” müssen. User U klickt also auf zum Beispiel auf einen Button “Login mit Provider P”, gibt seinen Grant ab und wird wieder zur Applikation A geleitet. Applikation A ruft im Hintergrund die User-Daten beim Provider P ab. Und genau hier wird es interessant.

In Deutschland regeln mehrere Gesetze, wie wir mit personenbezogenen Daten umgehen sollen. Und das ist auch gut so. Die wichtigsten sind aber wohl das Telemediengesetz (TMG), die Datenschutzgrundverordnung (DSGVO) und das Bundesdatenschutzgesetz (BDSG). In einigen Dingen überschneiden diese sich thematisch und im Ganzen decken die Normen eine breite Menge ab.

§ 71 BDSG (BDSG) verlangt “[…] angemessene Vorkehrungen zu treffen, die geeingnet sind, Datenschutzgrundsätze, wie etwa Datensparsamkeit wirksam umzusetzen. […].

Als Provider habe ich natürlich keine direkte Kontrolle darüber, was Client C mit den Daten des Users macht, die er erhält. Aber ich kann als Provider dafür Sorge tragen, dass Client C die Grundsätze der Datensparsamkeit einhalten kann, in dem er sich selbst von Daten “befreit”, die er zur Ausübung seines Dienstes nicht benötigt. Ein wenig konkreter: Provider P hat die folgenden Informationen zum User: Vorname, Nachname, E-Mail Adresse, Geburtsdatum. Client C benötigt jedoch nur den Vornamen und die E-Mail Adresse. Wäre es dann im Sinne des Users oder gar im Sinne der Datensparsamkeit, wenn dem Client C auch die Daten gesendet werden, die er gar nicht haben will? Und wäre es im Sinne des Client C, der dann auch sicherstellen muss, dass diese Daten weder gespeichert noch ausgewertet werden?

In der vorligenden Lösung biete ich einfach dem Client C die Möglichkeit an, selbst zu entscheiden, welche Daten er gerne hätte. Jede Änderung wird natürlich auch entsprechend festgehalten, sodass zu jedem Zeitpunkt klar ist, welche Daten vom Client C für die User abgerufen wurden. Eine nachträgliche Änderung führt zum Erlöschen der bisheringen Grants und AccessToken, sodass der User erneut zustimmen muss (diesen Teil zeige ich hier aber nicht).

Die Lösung

Django und Django-Rest-Framework

Als Provider kommt eine Django App zum Einsatz, die durch das Django-Rest-Framework erweitert wird. Als Provider Bibliotheken werden Python Social Auth und die dafür spezialisierte App für Django verwendet. In einem späteren Beitrag werde ich zeigen, wie sich ein OAuth2 Provider recht schnell selbst erstellen lässt mit den oben genannten Tools.

Application Model

Von der Bedinung her soll das Ganze recht einfach sein: Beim Anlegen der Application kann man einfach auswählen, welche Felder man gerne vom User hätte. Die werden dann später ausgelesen und eingebunden.

Daher sieht das Application Model wie folgt aus:

from django import forms
from django.db import models
from django.contrib.postgres.fields import ArrayField


class ChoiceArrayField(ArrayField):
    """
    A field that allows us to store an array of choices.

    Uses Django 1.9's postgres ArrayField
    and a MultipleChoiceField for its formfield.

    Usage: https://docs.djangoproject.com/en/3.0/ref/contrib/postgres/fields/#arrayfield
    """

    def formfield(self, **kwargs):
        defaults = {
            "form_class": forms.MultipleChoiceField,
            "choices": self.base_field.choices,
        }
        defaults.update(kwargs)
        return super(ArrayField, self).formfield(**defaults)


class UserFieldChoices(models.TextChoices):
    EMAIL = ("email", _("E-Mail"))
    FIRST_NAME = ("first_name", _("First name"))
    LAST_NAME = ("last_name", _("Last name"))
    DATE_OF_BIRTH = ("date_of_birth", _("Date of birth"))


class Application(models.Model):
    """
    A client is a representation of an application which,
    based on its features, gains access to the user data.
    The used protocol is OAuth2.

    https://github.com/jazzband/django-oauth-toolkit/blob/master/oauth2_provider/models.py#L21
    """

    CLIENT_CONFIDENTIAL = "confidential"
    CLIENT_PUBLIC = "public"
    CLIENT_TYPES = (
        (CLIENT_CONFIDENTIAL, _("Confidential")),
        (CLIENT_PUBLIC, _("Public")),
    )

    GRANT_AUTHORIZATION_CODE = "authorization-code"
    GRANT_IMPLICIT = "implicit"
    GRANT_PASSWORD = "password"
    GRANT_CLIENT_CREDENTIALS = "client-credentials"
    GRANT_TYPES = (
        (GRANT_AUTHORIZATION_CODE, _("Authorization code")),
        (GRANT_IMPLICIT, _("Implicit")),
        (GRANT_PASSWORD, _("Resource owner password-based")),
        (GRANT_CLIENT_CREDENTIALS, _("Client credentials")),
    )

    # Application
    name = models.CharField(
        verbose_name=_("Name"),
        max_length=255,
        blank=False,
        null=False,
        default="",
        help_text=_("The name of the application."),
    )

    client_id = models.CharField(
        verbose_name=_("Client ID"),
        max_length=100,
        blank=False,
        null=False,
        unique=True,
        db_index=True,
        default=generate_client_id,
    )

    client_secret = models.CharField(
        verbose_name=_("Client secret"),
        max_length=255,
        blank=True,
        null=False,
        db_index=True,
        default=generate_client_secret,
    )

    client_type = models.CharField(
        verbose_name=_("Client type"),
        max_length=32,
        blank=False,
        null=False,
        choices=CLIENT_TYPES,
        default=CLIENT_CONFIDENTIAL,
    )

    user = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        verbose_name=_("User"),
        related_name="%(app_label)s_%(class)s",
        on_delete=models.CASCADE,
        blank=True,
        null=True,
    )

    redirect_uris = models.TextField(
        verbose_name=_("Redirect URIs"),
        blank=True,
        null=False,
        help_text=_("Allowed URIs list, space separated"),
    )

    authorization_grant_type = models.CharField(
        verbose_name=_("Authorization grant type"),
        max_length=32,
        blank=False,
        null=False,
        choices=GRANT_TYPES,
        default=GRANT_AUTHORIZATION_CODE,
    )

    user_fields = ChoiceArrayField(
        models.CharField(
            verbose_name=_("User fields"),
            choices=UserFieldChoices.choices,
            max_length=16,
            blank=True,
            null=True,
            help_text=_(
                "This field declares what data should be sent to the application owner. "
                "The application owner can customize the set of user data he will receive."
            ),
        ),
        default=list,
        blank=True,
        null=True,
    )

    class Meta:
        verbose_name = _("Application")
        verbose_name_plural = _("Applications")

    def __str__(self):
        return self.name or self.client_id

Das ChoiceArrayField in Zeile 6 ist eine Klasse, die vom ArrayField erbt und das formfield() anpasst, damit das ArrayField als MultipleChoiceField im Django Admin angezeigt wird. Wer das nicht haben möchte, kann auch ganz einfach darauf verzichten und in Zeile 122 ArrayField stattdessen verwenden.

Das ist schon mal die halbe Magie, denn nun wird unter Application.user_fields (Zeile 122) eine Liste von Feldern gespeichert. Diese werden wir nun nutzen, um den Serializer dynamisch anzupassen.

UserSerializer

Dieser sieht dann wie folgt aus:

import copy

from collections import OrderedDict
from django.conf import settings
from rest_framework import serializers
from rest_framework.utils import model_meta

from apolocker.apps.authentication.models import User

from .employee import EmployeeSerializer
from ...schemas import USER_SCHEMA


class UserSerializer(serializers.ModelSerializer):
    class Meta:
        model = User
        fields = ("id",)
        read_only_fields = fields

    def get_fields(self):
        """
        We have to override the method to set the Meta.fields dynamically in
        the runtime.

        Original comment:
        Return the dict of field names -> field instances that should be
        used for `self.fields` when instantiating the serializer.
        """
        if self.url_field_name is None:
            self.url_field_name = settings.REST_FRAMEWORK["URL_FIELD_NAME"]

        self.__assert_correct_fields()

        # Set variables
        declared_fields = copy.deepcopy(self._declared_fields)
        model = getattr(self.Meta, "model")
        depth = getattr(self.Meta, "depth", 0)
        application = self.context["request"].auth.application

        if depth is not None:
            assert depth >= 0, "'depth' may not be negative."
            assert depth <= 10, "'depth' may not be greater than 10."

        # Get the user_fields form the application and set
        # the the Meta.fields.
        self.Meta.fields = list(field for field in application.user_fields)

        # This is where we want to cleanup `declared_fields`.
        # The variable self._decalred_fields contains all fields
        # declared in the serializer class. We have to remove the fields
        # from self._declared_fields that are not in Meta.fields to
        # prevent an AssertionError like:
        # AssertionError: The field 'organisation' was declared on serializer
        # UserSerializer, but has not been included in the 'fields' option.
        for field in declared_fields:
            if field not in self.Meta.fields:
                del declared_fields[field]

        return self.__get_fields(model, declared_fields, depth)

    def __assert_correct_fields(self):
        assert hasattr(
            self, "Meta"
        ), 'Class {serializer_class} missing "Meta" attribute'.format(
            serializer_class=self.__class__.__name__
        )

        assert hasattr(
            self.Meta, "model"
        ), 'Class {serializer_class} missing "Meta.model" attribute'.format(
            serializer_class=self.__class__.__name__
        )

        if model_meta.is_abstract_model(self.Meta.model):
            raise ValueError("Cannot use ModelSerializer with Abstract Models.")

    def __get_fields(self, model, declared_fields, depth):
        # Retrieve metadata about fields & relationships on the model class.
        info = model_meta.get_field_info(model)
        field_names = self.get_field_names(declared_fields, info)

        # Determine any extra field arguments and hidden fields that
        # should be included
        extra_kwargs = self.get_extra_kwargs()
        extra_kwargs, hidden_fields = self.get_uniqueness_extra_kwargs(
            field_names, declared_fields, extra_kwargs
        )

        # Determine the fields that should be included on the serializer.
        fields = OrderedDict()

        for field_name in field_names:
            # If the field is explicitly declared on the class then use that.
            if field_name in declared_fields:
                fields[field_name] = declared_fields[field_name]
                continue

            extra_field_kwargs = extra_kwargs.get(field_name, {})
            source = extra_field_kwargs.get("source", "*")
            if source == "*":
                source = field_name

            # Determine the serializer field class and keyword arguments.
            field_class, field_kwargs = self.build_field(source, info, model, depth)

            # Include any kwargs defined in `Meta.extra_kwargs`
            field_kwargs = self.include_extra_kwargs(field_kwargs, extra_field_kwargs)

            # Create the serializer field.
            fields[field_name] = field_class(**field_kwargs)

        # Add in any hidden fields.
        fields.update(hidden_fields)

        return fields

Der Großteil der Methode get_fields() stammt direkt aus der original Methode des Serializers. Aber Zeile 46 und Zeile 55-57 sind entscheidend. In Zeile 46 überschreiben wir den Inhalt von self.Meta.fields mit den Inhalten, die im Feld user_fields von Application drin sind. Anschließend müssen wir noch die Felder “säubern”, die zusätzlich zum Serializer hinzugefügt wurden. Das passiert in Zeile 55-57.

Welche Felder da genau entfernt werden, zeige ich an folgendem Beispiel. Nehmen wir einmal an, unser User hätte eine Organisation und diese könnte mit serialisiert werden. Dazu bräuchte der UserSerializer ein extra Feld organisation, welches einen OrganisationSerializer beinhaltet.

class UserSerializer(serializers.ModelSerializer):

    organisation = OrganisationSerializer()
    
    class Meta:
        model = User
        fields = "__all__"

Die Methode get_fields holt sich alle Felder, die in dem Serializer definiert wurden, in Zeile 35 und speichert diese in der Variable declared_fields. declared_fields sind also die Felder, die im Serializer deklariert wurden. Das Feld wird nun mitunter dazu verwendet, um die Daten zu validieren und zu prüfen, ob diese auch in Model.fields enthalten sind. Genau da haben wir dann ein Problem, wenn wir zum Beispiel als Client das Organisationsfeld gar nicht haben wollen. Deswegen müssen wir diese Prüfung in den Zeilen 55-57 vornehmen und das Feld declared_fields manipulieren.

Fazit

Die Implementierung ist recht simpel und funktional. Dadurch wird kurzerhand gewährleistet, dass dokumentiert (nicht hier gezeigt) festgehalten werden kann, welcher Client welche Daten von welchem User erhält und, dass der Client auch wirklich nur die erhält, die er braucht.

Über Feedback und Anregungen freue ich mich immer gerne. ;)