🔒️(backend) configure throttle on every viewsets

We want to configure the throttle on all doc's viewsets. In order to
monitor them, we use the MonitoredScopedRateThrottle class and a custom
callback caputing the message in sentry at the warning level.
This commit is contained in:
Manuel Raynaud
2025-09-05 15:29:08 +02:00
parent 179a84150b
commit 0ac9f059b6
12 changed files with 235 additions and 13 deletions

View File

@@ -8,6 +8,10 @@ and this project adheres to
## [Unreleased] ## [Unreleased]
### Changed
- 🔒️(backend) configure throttle on every viewsets #1343
## [3.6.0] - 2025-09-04 ## [3.6.0] - 2025-09-04
### Added ### Added

View File

@@ -3,3 +3,7 @@ BURST_THROTTLE_RATES="200/minute"
COLLABORATION_API_URL=http://y-provider:4444/collaboration/api/ COLLABORATION_API_URL=http://y-provider:4444/collaboration/api/
SUSTAINED_THROTTLE_RATES="200/hour" SUSTAINED_THROTTLE_RATES="200/hour"
Y_PROVIDER_API_BASE_URL=http://y-provider:4444/api/ Y_PROVIDER_API_BASE_URL=http://y-provider:4444/api/
# Throttle
API_DOCUMENT_THROTTLE_RATE=1000/min
API_CONFIG_THROTTLE_RATE=1000/min

View File

@@ -0,0 +1,21 @@
"""Throttling modules for the API."""
from rest_framework.throttling import UserRateThrottle
from sentry_sdk import capture_message
def sentry_monitoring_throttle_failure(message):
"""Log when a failure occurs to detect rate limiting issues."""
capture_message(message, "warning")
class UserListThrottleBurst(UserRateThrottle):
"""Throttle for the user list endpoint."""
scope = "user_list_burst"
class UserListThrottleSustained(UserRateThrottle):
"""Throttle for the user list endpoint."""
scope = "user_list_sustained"

View File

@@ -33,7 +33,6 @@ from lasuite.malware_detection import malware_detection
from rest_framework import filters, status, viewsets from rest_framework import filters, status, viewsets
from rest_framework import response as drf_response from rest_framework import response as drf_response
from rest_framework.permissions import AllowAny from rest_framework.permissions import AllowAny
from rest_framework.throttling import UserRateThrottle
from core import authentication, choices, enums, models from core import authentication, choices, enums, models
from core.services.ai_services import AIService from core.services.ai_services import AIService
@@ -43,6 +42,7 @@ from core.utils import extract_attachments, filter_descendants
from . import permissions, serializers, utils from . import permissions, serializers, utils
from .filters import DocumentFilter, ListDocumentFilter from .filters import DocumentFilter, ListDocumentFilter
from .throttling import UserListThrottleBurst, UserListThrottleSustained
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -136,18 +136,6 @@ class Pagination(drf.pagination.PageNumberPagination):
page_size_query_param = "page_size" page_size_query_param = "page_size"
class UserListThrottleBurst(UserRateThrottle):
"""Throttle for the user list endpoint."""
scope = "user_list_burst"
class UserListThrottleSustained(UserRateThrottle):
"""Throttle for the user list endpoint."""
scope = "user_list_sustained"
class UserViewSet( class UserViewSet(
drf.mixins.UpdateModelMixin, viewsets.GenericViewSet, drf.mixins.ListModelMixin drf.mixins.UpdateModelMixin, viewsets.GenericViewSet, drf.mixins.ListModelMixin
): ):
@@ -360,6 +348,7 @@ class DocumentViewSet(
permission_classes = [ permission_classes = [
permissions.DocumentPermission, permissions.DocumentPermission,
] ]
throttle_scope = "document"
queryset = models.Document.objects.select_related("creator").all() queryset = models.Document.objects.select_related("creator").all()
serializer_class = serializers.DocumentSerializer serializer_class = serializers.DocumentSerializer
ai_translate_serializer_class = serializers.AITranslateSerializer ai_translate_serializer_class = serializers.AITranslateSerializer
@@ -1555,6 +1544,7 @@ class DocumentAccessViewSet(
"document__depth", "document__depth",
) )
resource_field_name = "document" resource_field_name = "document"
throttle_scope = "document_access"
@cached_property @cached_property
def document(self): def document(self):
@@ -1714,6 +1704,7 @@ class TemplateViewSet(
permissions.IsAuthenticatedOrSafe, permissions.IsAuthenticatedOrSafe,
permissions.ResourceWithAccessPermission, permissions.ResourceWithAccessPermission,
] ]
throttle_scope = "template"
ordering = ["-created_at"] ordering = ["-created_at"]
ordering_fields = ["created_at", "updated_at", "title"] ordering_fields = ["created_at", "updated_at", "title"]
serializer_class = serializers.TemplateSerializer serializer_class = serializers.TemplateSerializer
@@ -1804,6 +1795,7 @@ class TemplateAccessViewSet(
lookup_field = "pk" lookup_field = "pk"
permission_classes = [permissions.ResourceAccessPermission] permission_classes = [permissions.ResourceAccessPermission]
throttle_scope = "template_access"
queryset = models.TemplateAccess.objects.select_related("user").all() queryset = models.TemplateAccess.objects.select_related("user").all()
resource_field_name = "template" resource_field_name = "template"
serializer_class = serializers.TemplateAccessSerializer serializer_class = serializers.TemplateAccessSerializer
@@ -1886,6 +1878,7 @@ class InvitationViewset(
permissions.CanCreateInvitationPermission, permissions.CanCreateInvitationPermission,
permissions.ResourceWithAccessPermission, permissions.ResourceWithAccessPermission,
] ]
throttle_scope = "invitation"
queryset = ( queryset = (
models.Invitation.objects.all() models.Invitation.objects.all()
.select_related("document") .select_related("document")
@@ -1964,6 +1957,7 @@ class DocumentAskForAccessViewSet(
permissions.IsAuthenticated, permissions.IsAuthenticated,
permissions.ResourceWithAccessPermission, permissions.ResourceWithAccessPermission,
] ]
throttle_scope = "document_ask_for_access"
queryset = models.DocumentAskForAccess.objects.all() queryset = models.DocumentAskForAccess.objects.all()
serializer_class = serializers.DocumentAskForAccessSerializer serializer_class = serializers.DocumentAskForAccessSerializer
_document = None _document = None
@@ -2036,6 +2030,7 @@ class ConfigView(drf.views.APIView):
"""API ViewSet for sharing some public settings.""" """API ViewSet for sharing some public settings."""
permission_classes = [AllowAny] permission_classes = [AllowAny]
throttle_scope = "config"
def get(self, request): def get(self, request):
""" """

View File

@@ -4,6 +4,7 @@ Test document accesses API endpoints for users in impress's core app.
# pylint: disable=too-many-lines # pylint: disable=too-many-lines
import random import random
from unittest import mock
from uuid import uuid4 from uuid import uuid4
import pytest import pytest
@@ -1344,3 +1345,24 @@ def test_api_document_accesses_delete_owners_last_owner_child_team(
assert response.status_code == 204 assert response.status_code == 204
assert models.DocumentAccess.objects.count() == 1 assert models.DocumentAccess.objects.count() == 1
def test_api_document_accesses_throttling(settings):
"""Test api document accesses throttling."""
settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["document_access"] = "2/minute"
user = factories.UserFactory()
document = factories.DocumentFactory()
factories.UserDocumentAccessFactory(
document=document, user=user, role="administrator"
)
client = APIClient()
client.force_login(user)
for _i in range(2):
response = client.get(f"/api/v1.0/documents/{document.id!s}/accesses/")
assert response.status_code == 200
with mock.patch("core.api.throttling.capture_message") as mock_capture_message:
response = client.get(f"/api/v1.0/documents/{document.id!s}/accesses/")
assert response.status_code == 429
mock_capture_message.assert_called_once_with(
"Rate limit exceeded for scope document_access", "warning"
)

View File

@@ -824,3 +824,29 @@ def test_api_document_invitations_delete_readers_or_editors(via, role, mock_user
response.json()["detail"] response.json()["detail"]
== "You do not have permission to perform this action." == "You do not have permission to perform this action."
) )
def test_api_document_invitations_throttling(settings):
"""Test api document ask for access throttling."""
current_rate = settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["invitation"]
settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["invitation"] = "2/minute"
user = factories.UserFactory()
document = factories.DocumentFactory()
factories.UserDocumentAccessFactory(document=document, user=user, role="owner")
factories.InvitationFactory(document=document, issuer=user)
client = APIClient()
client.force_login(user)
for _i in range(2):
response = client.get(f"/api/v1.0/documents/{document.id}/invitations/")
assert response.status_code == 200
with mock.patch("core.api.throttling.capture_message") as mock_capture_message:
response = client.get(f"/api/v1.0/documents/{document.id}/invitations/")
assert response.status_code == 429
mock_capture_message.assert_called_once_with(
"Rate limit exceeded for scope invitation", "warning"
)
settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["invitation"] = current_rate

View File

@@ -1,6 +1,7 @@
"""Test API for document ask for access.""" """Test API for document ask for access."""
import uuid import uuid
from unittest import mock
from django.core import mail from django.core import mail
@@ -768,3 +769,35 @@ def test_api_documents_ask_for_access_accept_authenticated_non_root_document(rol
f"/api/v1.0/documents/{child.id}/ask-for-access/{document_ask_for_access.id}/accept/" f"/api/v1.0/documents/{child.id}/ask-for-access/{document_ask_for_access.id}/accept/"
) )
assert response.status_code == 404 assert response.status_code == 404
def test_api_document_ask_for_access_throttling(settings):
"""Test api document ask for access throttling."""
current_rate = settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"][
"document_ask_for_access"
]
settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["document_ask_for_access"] = (
"2/minute"
)
document = DocumentFactory()
DocumentAskForAccessFactory.create_batch(
3, document=document, role=RoleChoices.READER
)
user = UserFactory()
client = APIClient()
client.force_login(user)
for _i in range(2):
response = client.get(f"/api/v1.0/documents/{document.id}/ask-for-access/")
assert response.status_code == 200
with mock.patch("core.api.throttling.capture_message") as mock_capture_message:
response = client.get(f"/api/v1.0/documents/{document.id}/ask-for-access/")
assert response.status_code == 429
mock_capture_message.assert_called_once_with(
"Rate limit exceeded for scope document_ask_for_access", "warning"
)
settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["document_ask_for_access"] = (
current_rate
)

View File

@@ -427,3 +427,20 @@ def test_api_documents_list_favorites_no_extra_queries(django_assert_num_queries
assert result["is_favorite"] is True assert result["is_favorite"] is True
else: else:
assert result["is_favorite"] is False assert result["is_favorite"] is False
def test_api_documents_list_throttling(settings):
"""Test api documents throttling."""
current_rate = settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["document"]
settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["document"] = "2/minute"
client = APIClient()
for _i in range(2):
response = client.get("/api/v1.0/documents/")
assert response.status_code == 200
with mock.patch("core.api.throttling.capture_message") as mock_capture_message:
response = client.get("/api/v1.0/documents/")
assert response.status_code == 429
mock_capture_message.assert_called_once_with(
"Rate limit exceeded for scope document", "warning"
)
settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["document"] = current_rate

View File

@@ -3,6 +3,7 @@ Test template accesses API endpoints for users in impress's core app.
""" """
import random import random
from unittest import mock
from uuid import uuid4 from uuid import uuid4
import pytest import pytest
@@ -773,3 +774,26 @@ def test_api_template_accesses_delete_owners_last_owner(via, mock_user_teams):
assert response.status_code == 403 assert response.status_code == 403
assert models.TemplateAccess.objects.count() == 2 assert models.TemplateAccess.objects.count() == 2
def test_api_template_accesses_throttling(settings):
"""Test api template accesses throttling."""
current_rate = settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["template_access"]
settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["template_access"] = "2/minute"
template = factories.TemplateFactory()
user = factories.UserFactory()
factories.UserTemplateAccessFactory(
template=template, user=user, role="administrator"
)
client = APIClient()
client.force_login(user)
for _i in range(2):
response = client.get(f"/api/v1.0/templates/{template.id!s}/accesses/")
assert response.status_code == 200
with mock.patch("core.api.throttling.capture_message") as mock_capture_message:
response = client.get(f"/api/v1.0/templates/{template.id!s}/accesses/")
assert response.status_code == 429
mock_capture_message.assert_called_once_with(
"Rate limit exceeded for scope template_access", "warning"
)
settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["template_access"] = current_rate

View File

@@ -218,3 +218,20 @@ def test_api_templates_list_order_param():
assert response_template_ids == templates_ids, ( assert response_template_ids == templates_ids, (
"created_at values are not sorted from oldest to newest" "created_at values are not sorted from oldest to newest"
) )
def test_api_template_throttling(settings):
"""Test api template throttling."""
current_rate = settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["template"]
settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["template"] = "2/minute"
client = APIClient()
for _i in range(2):
response = client.get("/api/v1.0/templates/")
assert response.status_code == 200
with mock.patch("core.api.throttling.capture_message") as mock_capture_message:
response = client.get("/api/v1.0/templates/")
assert response.status_code == 429
mock_capture_message.assert_called_once_with(
"Rate limit exceeded for scope template", "warning"
)
settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["template"] = current_rate

View File

@@ -3,6 +3,7 @@ Test config API endpoints in the Impress core app.
""" """
import json import json
from unittest.mock import patch
from django.test import override_settings from django.test import override_settings
@@ -174,3 +175,20 @@ def test_api_config_with_original_theme_customization(is_authenticated, settings
theme_customization = json.load(f) theme_customization = json.load(f)
assert content["theme_customization"] == theme_customization assert content["theme_customization"] == theme_customization
def test_api_config_throttling(settings):
"""Test api config throttling."""
current_rate = settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["config"]
settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["config"] = "2/minute"
client = APIClient()
for _i in range(2):
response = client.get("/api/v1.0/config/")
assert response.status_code == 200
with patch("core.api.throttling.capture_message") as mock_capture_message:
response = client.get("/api/v1.0/config/")
assert response.status_code == 429
mock_capture_message.assert_called_once_with(
"Rate limit exceeded for scope config", "warning"
)
settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["config"] = current_rate

View File

@@ -356,6 +356,9 @@ class Base(Configuration):
"PAGE_SIZE": 20, "PAGE_SIZE": 20,
"DEFAULT_VERSIONING_CLASS": "rest_framework.versioning.URLPathVersioning", "DEFAULT_VERSIONING_CLASS": "rest_framework.versioning.URLPathVersioning",
"DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema", "DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema",
"DEFAULT_THROTTLE_CLASSES": [
"lasuite.drf.throttling.MonitoredScopedRateThrottle",
],
"DEFAULT_THROTTLE_RATES": { "DEFAULT_THROTTLE_RATES": {
"user_list_sustained": values.Value( "user_list_sustained": values.Value(
default="180/hour", default="180/hour",
@@ -367,8 +370,46 @@ class Base(Configuration):
environ_name="API_USERS_LIST_THROTTLE_RATE_BURST", environ_name="API_USERS_LIST_THROTTLE_RATE_BURST",
environ_prefix=None, environ_prefix=None,
), ),
"document": values.Value(
default="80/minute",
environ_name="API_DOCUMENT_THROTTLE_RATE",
environ_prefix=None,
),
"document_access": values.Value(
default="50/minute",
environ_name="API_DOCUMENT_ACCESS_THROTTLE_RATE",
environ_prefix=None,
),
"template": values.Value(
default="30/minute",
environ_name="API_TEMPLATE_THROTTLE_RATE",
environ_prefix=None,
),
"template_access": values.Value(
default="30/minute",
environ_name="API_TEMPLATE_ACCESS_THROTTLE_RATE",
environ_prefix=None,
),
"invitation": values.Value(
default="60/minute",
environ_name="API_INVITATION_THROTTLE_RATE",
environ_prefix=None,
),
"document_ask_for_access": values.Value(
default="30/minute",
environ_name="API_DOCUMENT_ASK_FOR_ACCESS_THROTTLE_RATE",
environ_prefix=None,
),
"config": values.Value(
default="30/minute",
environ_name="API_CONFIG_THROTTLE_RATE",
environ_prefix=None,
),
}, },
} }
MONITORED_THROTTLE_FAILURE_CALLBACK = (
"core.api.throttling.sentry_monitoring_throttle_failure"
)
SPECTACULAR_SETTINGS = { SPECTACULAR_SETTINGS = {
"TITLE": "Impress API", "TITLE": "Impress API",