authentik.admin.files.api

  1from django.db.models import Q
  2from django.utils.translation import gettext as _
  3from drf_spectacular.utils import extend_schema
  4from guardian.shortcuts import get_objects_for_user
  5from rest_framework.exceptions import ValidationError
  6from rest_framework.fields import BooleanField, CharField, ChoiceField, FileField
  7from rest_framework.parsers import MultiPartParser
  8from rest_framework.permissions import SAFE_METHODS
  9from rest_framework.request import Request
 10from rest_framework.response import Response
 11from rest_framework.views import APIView
 12
 13from authentik.admin.files.backends.base import get_content_type
 14from authentik.admin.files.fields import FileField as AkFileField
 15from authentik.admin.files.manager import get_file_manager
 16from authentik.admin.files.usage import FileApiUsage
 17from authentik.admin.files.validation import validate_upload_file_name
 18from authentik.api.validation import validate
 19from authentik.core.api.used_by import DeleteAction, UsedBySerializer
 20from authentik.core.api.utils import PassiveSerializer, ThemedUrlsSerializer
 21from authentik.events.models import Event, EventAction
 22from authentik.lib.utils.reflection import get_apps
 23from authentik.rbac.permissions import HasPermission
 24
 25MAX_FILE_SIZE_BYTES = 25 * 1024 * 1024  # 25MB
 26
 27
 28class FileView(APIView):
 29    pagination_class = None
 30    parser_classes = [MultiPartParser]
 31
 32    def get_permissions(self):
 33        return [
 34            HasPermission(
 35                "authentik_rbac.view_media_files"
 36                if self.request.method in SAFE_METHODS
 37                else "authentik_rbac.manage_media_files"
 38            )()
 39        ]
 40
 41    class FileListParameters(PassiveSerializer):
 42        usage = ChoiceField(choices=list(FileApiUsage), default=FileApiUsage.MEDIA.value)
 43        search = CharField(required=False)
 44        manageable_only = BooleanField(required=False, default=False)
 45
 46    class FileListSerializer(PassiveSerializer):
 47        name = CharField()
 48        mime_type = CharField()
 49        url = CharField()
 50        themed_urls = ThemedUrlsSerializer(required=False, allow_null=True)
 51
 52    @extend_schema(
 53        parameters=[FileListParameters],
 54        responses={200: FileListSerializer(many=True)},
 55    )
 56    @validate(FileListParameters, location="query")
 57    def get(self, request: Request, query: FileListParameters) -> Response:
 58        """List files from storage backend."""
 59        params = query.validated_data
 60
 61        try:
 62            usage = FileApiUsage(params.get("usage", FileApiUsage.MEDIA.value))
 63        except ValueError as exc:
 64            raise ValidationError(
 65                f"Invalid usage parameter provided: {params.get('usage')}"
 66            ) from exc
 67
 68        # Backend is source of truth - list all files from storage
 69        manager = get_file_manager(usage)
 70        files = manager.list_files(manageable_only=params.get("manageable_only", False))
 71        search_query = params.get("search", "")
 72        if search_query:
 73            files = filter(lambda file: search_query in file.lower(), files)
 74        files = [
 75            FileView.FileListSerializer(
 76                data={
 77                    "name": file,
 78                    "url": manager.file_url(file, request),
 79                    "mime_type": get_content_type(file),
 80                    "themed_urls": manager.themed_urls(file, request),
 81                }
 82            )
 83            for file in files
 84        ]
 85        for file in files:
 86            file.is_valid(raise_exception=True)
 87
 88        return Response([file.data for file in files])
 89
 90    class FileUploadSerializer(PassiveSerializer):
 91        file = FileField(required=True)
 92        name = CharField(required=False, allow_blank=True)
 93        usage = CharField(required=False, default=FileApiUsage.MEDIA.value)
 94
 95    @extend_schema(
 96        request=FileUploadSerializer,
 97        responses={200: None},
 98    )
 99    @validate(FileUploadSerializer)
100    def post(self, request: Request, body: FileUploadSerializer) -> Response:
101        """Upload file to storage backend."""
102        file = body.validated_data["file"]
103        name = body.validated_data.get("name", "").strip()
104        usage_value = body.validated_data.get("usage", FileApiUsage.MEDIA.value)
105
106        # Validate file size and type
107        if file.size > MAX_FILE_SIZE_BYTES:
108            raise ValidationError(
109                {
110                    "file": [
111                        _(
112                            f"File size ({file.size}B) exceeds maximum allowed "
113                            f"size ({MAX_FILE_SIZE_BYTES}B)."
114                        )
115                    ]
116                }
117            )
118
119        try:
120            usage = FileApiUsage(usage_value)
121        except ValueError as exc:
122            raise ValidationError(f"Invalid usage parameter provided: {usage_value}") from exc
123
124        # Use original filename
125        if not name:
126            name = file.name
127
128        # Sanitize path to prevent directory traversal
129        validate_upload_file_name(name, ValidationError)
130
131        manager = get_file_manager(usage)
132
133        # Check if file already exists
134        if manager.file_exists(name):
135            raise ValidationError({"name": ["A file with this name already exists."]})
136
137        # Save to backend
138        with manager.save_file_stream(name) as f:
139            f.write(file.read())
140
141        Event.new(
142            EventAction.MODEL_CREATED,
143            model={
144                "app": "authentik_admin_files",
145                "model_name": "File",
146                "pk": name,
147                "name": name,
148                "usage": usage.value,
149                "mime_type": get_content_type(name),
150            },
151        ).from_http(request)
152
153        return Response()
154
155    class FileDeleteParameters(PassiveSerializer):
156        name = CharField()
157        usage = ChoiceField(choices=list(FileApiUsage), default=FileApiUsage.MEDIA.value)
158
159    @extend_schema(
160        parameters=[FileDeleteParameters],
161        responses={200: None},
162    )
163    @validate(FileDeleteParameters, location="query")
164    def delete(self, request: Request, query: FileDeleteParameters) -> Response:
165        """Delete file from storage backend."""
166        params = query.validated_data
167
168        validate_upload_file_name(params.get("name", ""), ValidationError)
169
170        try:
171            usage = FileApiUsage(params.get("usage", FileApiUsage.MEDIA.value))
172        except ValueError as exc:
173            raise ValidationError(
174                f"Invalid usage parameter provided: {params.get('usage')}"
175            ) from exc
176
177        manager = get_file_manager(usage)
178
179        # Delete from backend
180        manager.delete_file(params.get("name"))
181
182        # Audit log for file deletion
183        Event.new(
184            EventAction.MODEL_DELETED,
185            model={
186                "app": "authentik_admin_files",
187                "model_name": "File",
188                "pk": params.get("name"),
189                "name": params.get("name"),
190                "usage": usage.value,
191            },
192        ).from_http(request)
193
194        return Response()
195
196
197class FileUsedByView(APIView):
198    pagination_class = None
199
200    def get_permissions(self):
201        return [
202            HasPermission(
203                "authentik_rbac.view_media_files"
204                if self.request.method in SAFE_METHODS
205                else "authentik_rbac.manage_media_files"
206            )()
207        ]
208
209    class FileUsedByParameters(PassiveSerializer):
210        name = CharField()
211
212    @extend_schema(
213        parameters=[FileUsedByParameters],
214        responses={200: UsedBySerializer(many=True)},
215    )
216    @validate(FileUsedByParameters, location="query")
217    def get(self, request: Request, query: FileUsedByParameters) -> Response:
218        params = query.validated_data
219
220        models_and_fields = {}
221        for app in get_apps():
222            for model in app.get_models():
223                if model._meta.abstract:
224                    continue
225                for field in model._meta.get_fields():
226                    if isinstance(field, AkFileField):
227                        models_and_fields.setdefault(model, []).append(field.name)
228
229        used_by = []
230
231        for model, fields in models_and_fields.items():
232            app = model._meta.app_label
233            model_name = model._meta.model_name
234
235            q = Q()
236            for field in fields:
237                q |= Q(**{field: params.get("name")})
238
239            objs = get_objects_for_user(
240                request.user, f"{app}.view_{model_name}", model.objects.all()
241            )
242            objs = objs.filter(q)
243            for obj in objs:
244                serializer = UsedBySerializer(
245                    data={
246                        "app": model._meta.app_label,
247                        "model_name": model._meta.model_name,
248                        "pk": str(obj.pk),
249                        "name": str(obj),
250                        "action": DeleteAction.LEFT_DANGLING,
251                    }
252                )
253                serializer.is_valid()
254                used_by.append(serializer.data)
255
256        return Response(used_by)
MAX_FILE_SIZE_BYTES = 26214400
class FileView(rest_framework.views.APIView):
 29class FileView(APIView):
 30    pagination_class = None
 31    parser_classes = [MultiPartParser]
 32
 33    def get_permissions(self):
 34        return [
 35            HasPermission(
 36                "authentik_rbac.view_media_files"
 37                if self.request.method in SAFE_METHODS
 38                else "authentik_rbac.manage_media_files"
 39            )()
 40        ]
 41
 42    class FileListParameters(PassiveSerializer):
 43        usage = ChoiceField(choices=list(FileApiUsage), default=FileApiUsage.MEDIA.value)
 44        search = CharField(required=False)
 45        manageable_only = BooleanField(required=False, default=False)
 46
 47    class FileListSerializer(PassiveSerializer):
 48        name = CharField()
 49        mime_type = CharField()
 50        url = CharField()
 51        themed_urls = ThemedUrlsSerializer(required=False, allow_null=True)
 52
 53    @extend_schema(
 54        parameters=[FileListParameters],
 55        responses={200: FileListSerializer(many=True)},
 56    )
 57    @validate(FileListParameters, location="query")
 58    def get(self, request: Request, query: FileListParameters) -> Response:
 59        """List files from storage backend."""
 60        params = query.validated_data
 61
 62        try:
 63            usage = FileApiUsage(params.get("usage", FileApiUsage.MEDIA.value))
 64        except ValueError as exc:
 65            raise ValidationError(
 66                f"Invalid usage parameter provided: {params.get('usage')}"
 67            ) from exc
 68
 69        # Backend is source of truth - list all files from storage
 70        manager = get_file_manager(usage)
 71        files = manager.list_files(manageable_only=params.get("manageable_only", False))
 72        search_query = params.get("search", "")
 73        if search_query:
 74            files = filter(lambda file: search_query in file.lower(), files)
 75        files = [
 76            FileView.FileListSerializer(
 77                data={
 78                    "name": file,
 79                    "url": manager.file_url(file, request),
 80                    "mime_type": get_content_type(file),
 81                    "themed_urls": manager.themed_urls(file, request),
 82                }
 83            )
 84            for file in files
 85        ]
 86        for file in files:
 87            file.is_valid(raise_exception=True)
 88
 89        return Response([file.data for file in files])
 90
 91    class FileUploadSerializer(PassiveSerializer):
 92        file = FileField(required=True)
 93        name = CharField(required=False, allow_blank=True)
 94        usage = CharField(required=False, default=FileApiUsage.MEDIA.value)
 95
 96    @extend_schema(
 97        request=FileUploadSerializer,
 98        responses={200: None},
 99    )
100    @validate(FileUploadSerializer)
101    def post(self, request: Request, body: FileUploadSerializer) -> Response:
102        """Upload file to storage backend."""
103        file = body.validated_data["file"]
104        name = body.validated_data.get("name", "").strip()
105        usage_value = body.validated_data.get("usage", FileApiUsage.MEDIA.value)
106
107        # Validate file size and type
108        if file.size > MAX_FILE_SIZE_BYTES:
109            raise ValidationError(
110                {
111                    "file": [
112                        _(
113                            f"File size ({file.size}B) exceeds maximum allowed "
114                            f"size ({MAX_FILE_SIZE_BYTES}B)."
115                        )
116                    ]
117                }
118            )
119
120        try:
121            usage = FileApiUsage(usage_value)
122        except ValueError as exc:
123            raise ValidationError(f"Invalid usage parameter provided: {usage_value}") from exc
124
125        # Use original filename
126        if not name:
127            name = file.name
128
129        # Sanitize path to prevent directory traversal
130        validate_upload_file_name(name, ValidationError)
131
132        manager = get_file_manager(usage)
133
134        # Check if file already exists
135        if manager.file_exists(name):
136            raise ValidationError({"name": ["A file with this name already exists."]})
137
138        # Save to backend
139        with manager.save_file_stream(name) as f:
140            f.write(file.read())
141
142        Event.new(
143            EventAction.MODEL_CREATED,
144            model={
145                "app": "authentik_admin_files",
146                "model_name": "File",
147                "pk": name,
148                "name": name,
149                "usage": usage.value,
150                "mime_type": get_content_type(name),
151            },
152        ).from_http(request)
153
154        return Response()
155
156    class FileDeleteParameters(PassiveSerializer):
157        name = CharField()
158        usage = ChoiceField(choices=list(FileApiUsage), default=FileApiUsage.MEDIA.value)
159
160    @extend_schema(
161        parameters=[FileDeleteParameters],
162        responses={200: None},
163    )
164    @validate(FileDeleteParameters, location="query")
165    def delete(self, request: Request, query: FileDeleteParameters) -> Response:
166        """Delete file from storage backend."""
167        params = query.validated_data
168
169        validate_upload_file_name(params.get("name", ""), ValidationError)
170
171        try:
172            usage = FileApiUsage(params.get("usage", FileApiUsage.MEDIA.value))
173        except ValueError as exc:
174            raise ValidationError(
175                f"Invalid usage parameter provided: {params.get('usage')}"
176            ) from exc
177
178        manager = get_file_manager(usage)
179
180        # Delete from backend
181        manager.delete_file(params.get("name"))
182
183        # Audit log for file deletion
184        Event.new(
185            EventAction.MODEL_DELETED,
186            model={
187                "app": "authentik_admin_files",
188                "model_name": "File",
189                "pk": params.get("name"),
190                "name": params.get("name"),
191                "usage": usage.value,
192            },
193        ).from_http(request)
194
195        return Response()

Intentionally simple parent class for all views. Only implements dispatch-by-method and simple sanity checking.

pagination_class = None
parser_classes = [<class 'rest_framework.parsers.MultiPartParser'>]
def get_permissions(self):
33    def get_permissions(self):
34        return [
35            HasPermission(
36                "authentik_rbac.view_media_files"
37                if self.request.method in SAFE_METHODS
38                else "authentik_rbac.manage_media_files"
39            )()
40        ]

Instantiates and returns the list of permissions that this view requires.

@extend_schema(parameters=[FileListParameters], responses={200: FileListSerializer(many=True)})
@validate(FileListParameters, location='query')
def get( self, request: rest_framework.request.Request, query: FileView.FileListParameters) -> rest_framework.response.Response:
53    @extend_schema(
54        parameters=[FileListParameters],
55        responses={200: FileListSerializer(many=True)},
56    )
57    @validate(FileListParameters, location="query")
58    def get(self, request: Request, query: FileListParameters) -> Response:
59        """List files from storage backend."""
60        params = query.validated_data
61
62        try:
63            usage = FileApiUsage(params.get("usage", FileApiUsage.MEDIA.value))
64        except ValueError as exc:
65            raise ValidationError(
66                f"Invalid usage parameter provided: {params.get('usage')}"
67            ) from exc
68
69        # Backend is source of truth - list all files from storage
70        manager = get_file_manager(usage)
71        files = manager.list_files(manageable_only=params.get("manageable_only", False))
72        search_query = params.get("search", "")
73        if search_query:
74            files = filter(lambda file: search_query in file.lower(), files)
75        files = [
76            FileView.FileListSerializer(
77                data={
78                    "name": file,
79                    "url": manager.file_url(file, request),
80                    "mime_type": get_content_type(file),
81                    "themed_urls": manager.themed_urls(file, request),
82                }
83            )
84            for file in files
85        ]
86        for file in files:
87            file.is_valid(raise_exception=True)
88
89        return Response([file.data for file in files])

List files from storage backend.

@extend_schema(request=FileUploadSerializer, responses={200: None})
@validate(FileUploadSerializer)
def post( self, request: rest_framework.request.Request, body: FileView.FileUploadSerializer) -> rest_framework.response.Response:
 96    @extend_schema(
 97        request=FileUploadSerializer,
 98        responses={200: None},
 99    )
100    @validate(FileUploadSerializer)
101    def post(self, request: Request, body: FileUploadSerializer) -> Response:
102        """Upload file to storage backend."""
103        file = body.validated_data["file"]
104        name = body.validated_data.get("name", "").strip()
105        usage_value = body.validated_data.get("usage", FileApiUsage.MEDIA.value)
106
107        # Validate file size and type
108        if file.size > MAX_FILE_SIZE_BYTES:
109            raise ValidationError(
110                {
111                    "file": [
112                        _(
113                            f"File size ({file.size}B) exceeds maximum allowed "
114                            f"size ({MAX_FILE_SIZE_BYTES}B)."
115                        )
116                    ]
117                }
118            )
119
120        try:
121            usage = FileApiUsage(usage_value)
122        except ValueError as exc:
123            raise ValidationError(f"Invalid usage parameter provided: {usage_value}") from exc
124
125        # Use original filename
126        if not name:
127            name = file.name
128
129        # Sanitize path to prevent directory traversal
130        validate_upload_file_name(name, ValidationError)
131
132        manager = get_file_manager(usage)
133
134        # Check if file already exists
135        if manager.file_exists(name):
136            raise ValidationError({"name": ["A file with this name already exists."]})
137
138        # Save to backend
139        with manager.save_file_stream(name) as f:
140            f.write(file.read())
141
142        Event.new(
143            EventAction.MODEL_CREATED,
144            model={
145                "app": "authentik_admin_files",
146                "model_name": "File",
147                "pk": name,
148                "name": name,
149                "usage": usage.value,
150                "mime_type": get_content_type(name),
151            },
152        ).from_http(request)
153
154        return Response()

Upload file to storage backend.

@extend_schema(parameters=[FileDeleteParameters], responses={200: None})
@validate(FileDeleteParameters, location='query')
def delete( self, request: rest_framework.request.Request, query: FileView.FileDeleteParameters) -> rest_framework.response.Response:
160    @extend_schema(
161        parameters=[FileDeleteParameters],
162        responses={200: None},
163    )
164    @validate(FileDeleteParameters, location="query")
165    def delete(self, request: Request, query: FileDeleteParameters) -> Response:
166        """Delete file from storage backend."""
167        params = query.validated_data
168
169        validate_upload_file_name(params.get("name", ""), ValidationError)
170
171        try:
172            usage = FileApiUsage(params.get("usage", FileApiUsage.MEDIA.value))
173        except ValueError as exc:
174            raise ValidationError(
175                f"Invalid usage parameter provided: {params.get('usage')}"
176            ) from exc
177
178        manager = get_file_manager(usage)
179
180        # Delete from backend
181        manager.delete_file(params.get("name"))
182
183        # Audit log for file deletion
184        Event.new(
185            EventAction.MODEL_DELETED,
186            model={
187                "app": "authentik_admin_files",
188                "model_name": "File",
189                "pk": params.get("name"),
190                "name": params.get("name"),
191                "usage": usage.value,
192            },
193        ).from_http(request)
194
195        return Response()

Delete file from storage backend.

class FileView.FileListParameters(authentik.core.api.utils.PassiveSerializer):
42    class FileListParameters(PassiveSerializer):
43        usage = ChoiceField(choices=list(FileApiUsage), default=FileApiUsage.MEDIA.value)
44        search = CharField(required=False)
45        manageable_only = BooleanField(required=False, default=False)

Base serializer class which doesn't implement create/update methods

usage
search
manageable_only
class FileView.FileListSerializer(authentik.core.api.utils.PassiveSerializer):
47    class FileListSerializer(PassiveSerializer):
48        name = CharField()
49        mime_type = CharField()
50        url = CharField()
51        themed_urls = ThemedUrlsSerializer(required=False, allow_null=True)

Base serializer class which doesn't implement create/update methods

name
mime_type
url
themed_urls
class FileView.FileUploadSerializer(authentik.core.api.utils.PassiveSerializer):
91    class FileUploadSerializer(PassiveSerializer):
92        file = FileField(required=True)
93        name = CharField(required=False, allow_blank=True)
94        usage = CharField(required=False, default=FileApiUsage.MEDIA.value)

Base serializer class which doesn't implement create/update methods

file
name
usage
class FileView.FileDeleteParameters(authentik.core.api.utils.PassiveSerializer):
156    class FileDeleteParameters(PassiveSerializer):
157        name = CharField()
158        usage = ChoiceField(choices=list(FileApiUsage), default=FileApiUsage.MEDIA.value)

Base serializer class which doesn't implement create/update methods

name
usage
class FileUsedByView(rest_framework.views.APIView):
198class FileUsedByView(APIView):
199    pagination_class = None
200
201    def get_permissions(self):
202        return [
203            HasPermission(
204                "authentik_rbac.view_media_files"
205                if self.request.method in SAFE_METHODS
206                else "authentik_rbac.manage_media_files"
207            )()
208        ]
209
210    class FileUsedByParameters(PassiveSerializer):
211        name = CharField()
212
213    @extend_schema(
214        parameters=[FileUsedByParameters],
215        responses={200: UsedBySerializer(many=True)},
216    )
217    @validate(FileUsedByParameters, location="query")
218    def get(self, request: Request, query: FileUsedByParameters) -> Response:
219        params = query.validated_data
220
221        models_and_fields = {}
222        for app in get_apps():
223            for model in app.get_models():
224                if model._meta.abstract:
225                    continue
226                for field in model._meta.get_fields():
227                    if isinstance(field, AkFileField):
228                        models_and_fields.setdefault(model, []).append(field.name)
229
230        used_by = []
231
232        for model, fields in models_and_fields.items():
233            app = model._meta.app_label
234            model_name = model._meta.model_name
235
236            q = Q()
237            for field in fields:
238                q |= Q(**{field: params.get("name")})
239
240            objs = get_objects_for_user(
241                request.user, f"{app}.view_{model_name}", model.objects.all()
242            )
243            objs = objs.filter(q)
244            for obj in objs:
245                serializer = UsedBySerializer(
246                    data={
247                        "app": model._meta.app_label,
248                        "model_name": model._meta.model_name,
249                        "pk": str(obj.pk),
250                        "name": str(obj),
251                        "action": DeleteAction.LEFT_DANGLING,
252                    }
253                )
254                serializer.is_valid()
255                used_by.append(serializer.data)
256
257        return Response(used_by)

Intentionally simple parent class for all views. Only implements dispatch-by-method and simple sanity checking.

pagination_class = None
def get_permissions(self):
201    def get_permissions(self):
202        return [
203            HasPermission(
204                "authentik_rbac.view_media_files"
205                if self.request.method in SAFE_METHODS
206                else "authentik_rbac.manage_media_files"
207            )()
208        ]

Instantiates and returns the list of permissions that this view requires.

@extend_schema(parameters=[FileUsedByParameters], responses={200: UsedBySerializer(many=True)})
@validate(FileUsedByParameters, location='query')
def get( self, request: rest_framework.request.Request, query: FileUsedByView.FileUsedByParameters) -> rest_framework.response.Response:
213    @extend_schema(
214        parameters=[FileUsedByParameters],
215        responses={200: UsedBySerializer(many=True)},
216    )
217    @validate(FileUsedByParameters, location="query")
218    def get(self, request: Request, query: FileUsedByParameters) -> Response:
219        params = query.validated_data
220
221        models_and_fields = {}
222        for app in get_apps():
223            for model in app.get_models():
224                if model._meta.abstract:
225                    continue
226                for field in model._meta.get_fields():
227                    if isinstance(field, AkFileField):
228                        models_and_fields.setdefault(model, []).append(field.name)
229
230        used_by = []
231
232        for model, fields in models_and_fields.items():
233            app = model._meta.app_label
234            model_name = model._meta.model_name
235
236            q = Q()
237            for field in fields:
238                q |= Q(**{field: params.get("name")})
239
240            objs = get_objects_for_user(
241                request.user, f"{app}.view_{model_name}", model.objects.all()
242            )
243            objs = objs.filter(q)
244            for obj in objs:
245                serializer = UsedBySerializer(
246                    data={
247                        "app": model._meta.app_label,
248                        "model_name": model._meta.model_name,
249                        "pk": str(obj.pk),
250                        "name": str(obj),
251                        "action": DeleteAction.LEFT_DANGLING,
252                    }
253                )
254                serializer.is_valid()
255                used_by.append(serializer.data)
256
257        return Response(used_by)
class FileUsedByView.FileUsedByParameters(authentik.core.api.utils.PassiveSerializer):
210    class FileUsedByParameters(PassiveSerializer):
211        name = CharField()

Base serializer class which doesn't implement create/update methods

name