summary refs log tree commit diff
diff options
context:
space:
mode:
authorHugh Nimmo-Smith <hughns@users.noreply.github.com>2025-06-04 13:09:11 +0100
committerGitHub <noreply@github.com>2025-06-04 12:09:11 +0000
commit9b2bc75ed49ea52008dc787a09337ca4a843d8e7 (patch)
tree2035b651bd0452e2a4f8563b16565a285b0f6759
parentAdd user_may_send_state_event callback to spam checker module API (#18455) (diff)
downloadsynapse-9b2bc75ed49ea52008dc787a09337ca4a843d8e7.tar.xz
Add ratelimit callbacks to module API to allow dynamic ratelimiting (#18458)
-rw-r--r--changelog.d/18458.feature1
-rw-r--r--docs/SUMMARY.md3
-rw-r--r--docs/modules/ratelimit_callbacks.md33
-rw-r--r--synapse/api/ratelimiting.py24
-rw-r--r--synapse/handlers/room_member.py3
-rw-r--r--synapse/module_api/__init__.py19
-rw-r--r--synapse/module_api/callbacks/__init__.py4
-rw-r--r--synapse/module_api/callbacks/ratelimit_callbacks.py62
-rw-r--r--synapse/storage/databases/main/room.py2
-rw-r--r--tests/api/test_ratelimiting.py50
10 files changed, 197 insertions, 4 deletions
diff --git a/changelog.d/18458.feature b/changelog.d/18458.feature
new file mode 100644

index 0000000000..92f11a8a2c --- /dev/null +++ b/changelog.d/18458.feature
@@ -0,0 +1 @@ +Add a new module API callback that allows overriding of per user ratelimits. \ No newline at end of file diff --git a/docs/SUMMARY.md b/docs/SUMMARY.md
index abb1d5603c..40d70a4485 100644 --- a/docs/SUMMARY.md +++ b/docs/SUMMARY.md
@@ -49,7 +49,8 @@ - [Background update controller callbacks](modules/background_update_controller_callbacks.md) - [Account data callbacks](modules/account_data_callbacks.md) - [Add extra fields to client events unsigned section callbacks](modules/add_extra_fields_to_client_events_unsigned.md) - - [Media repository](modules/media_repository_callbacks.md) + - [Media repository callbacks](modules/media_repository_callbacks.md) + - [Ratelimit callbacks](modules/ratelimit_callbacks.md) - [Porting a legacy module to the new interface](modules/porting_legacy_module.md) - [Workers](workers.md) - [Using `synctl` with Workers](synctl_workers.md) diff --git a/docs/modules/ratelimit_callbacks.md b/docs/modules/ratelimit_callbacks.md new file mode 100644
index 0000000000..bf923c045e --- /dev/null +++ b/docs/modules/ratelimit_callbacks.md
@@ -0,0 +1,33 @@ +# Ratelimit callbacks + +Ratelimit callbacks allow module developers to override ratelimit settings dynamically whilst +Synapse is running. Ratelimit callbacks can be registered using the module API's +`register_ratelimit_callbacks` method. + +The available ratelimit callbacks are: + +### `get_ratelimit_override_for_user` + +_First introduced in Synapse v1.132.0_ + +```python +async def get_ratelimit_override_for_user(user: str, limiter_name: str) -> Optional[RatelimitOverride] +``` + +Called when constructing a ratelimiter of a particular type for a user. The module can +return a `messages_per_second` and `burst_count` to be used, or `None` if +the default settings are adequate. The user is represented by their Matrix user ID +(e.g. `@alice:example.com`). The limiter name is usually taken from the `RatelimitSettings` key +value. + +The limiters that are currently supported are: + +- `rc_invites.per_room` +- `rc_invites.per_user` +- `rc_invites.per_issuer` + +If multiple modules implement this callback, they will be considered in order. If a +callback returns `None`, Synapse falls through to the next one. The value of the first +callback that does not return `None` will be used. If this happens, Synapse will not call +any of the subsequent implementations of this callback. If no module returns a non-`None` value +then the default settings will be used. diff --git a/synapse/api/ratelimiting.py b/synapse/api/ratelimiting.py
index 8665b3b765..38e5bdaa75 100644 --- a/synapse/api/ratelimiting.py +++ b/synapse/api/ratelimiting.py
@@ -20,7 +20,7 @@ # # -from typing import Dict, Hashable, Optional, Tuple +from typing import TYPE_CHECKING, Dict, Hashable, Optional, Tuple from synapse.api.errors import LimitExceededError from synapse.config.ratelimiting import RatelimitSettings @@ -28,6 +28,12 @@ from synapse.storage.databases.main import DataStore from synapse.types import Requester from synapse.util import Clock +if TYPE_CHECKING: + # To avoid circular imports: + from synapse.module_api.callbacks.ratelimit_callbacks import ( + RatelimitModuleApiCallbacks, + ) + class Ratelimiter: """ @@ -72,12 +78,14 @@ class Ratelimiter: store: DataStore, clock: Clock, cfg: RatelimitSettings, + ratelimit_callbacks: Optional["RatelimitModuleApiCallbacks"] = None, ): self.clock = clock self.rate_hz = cfg.per_second self.burst_count = cfg.burst_count self.store = store self._limiter_name = cfg.key + self._ratelimit_callbacks = ratelimit_callbacks # A dictionary representing the token buckets tracked by this rate # limiter. Each entry maps a key of arbitrary type to a tuple representing: @@ -165,6 +173,20 @@ class Ratelimiter: if override and not override.messages_per_second: return True, -1.0 + if requester and self._ratelimit_callbacks: + # Check if the user has a custom rate limit for this specific limiter + # as returned by the module API. + module_override = ( + await self._ratelimit_callbacks.get_ratelimit_override_for_user( + requester.user.to_string(), + self._limiter_name, + ) + ) + + if module_override: + rate_hz = module_override.messages_per_second + burst_count = module_override.burst_count + # Override default values if set time_now_s = _time_now_s if _time_now_s is not None else self.clock.time() rate_hz = rate_hz if rate_hz is not None else self.rate_hz diff --git a/synapse/handlers/room_member.py b/synapse/handlers/room_member.py
index 70cbbc352b..b0e750c9c7 100644 --- a/synapse/handlers/room_member.py +++ b/synapse/handlers/room_member.py
@@ -158,6 +158,7 @@ class RoomMemberHandler(metaclass=abc.ABCMeta): store=self.store, clock=self.clock, cfg=hs.config.ratelimiting.rc_invites_per_room, + ratelimit_callbacks=hs.get_module_api_callbacks().ratelimit, ) # Ratelimiter for invites, keyed by recipient (across all rooms, all @@ -166,6 +167,7 @@ class RoomMemberHandler(metaclass=abc.ABCMeta): store=self.store, clock=self.clock, cfg=hs.config.ratelimiting.rc_invites_per_user, + ratelimit_callbacks=hs.get_module_api_callbacks().ratelimit, ) # Ratelimiter for invites, keyed by issuer (across all rooms, all @@ -174,6 +176,7 @@ class RoomMemberHandler(metaclass=abc.ABCMeta): store=self.store, clock=self.clock, cfg=hs.config.ratelimiting.rc_invites_per_issuer, + ratelimit_callbacks=hs.get_module_api_callbacks().ratelimit, ) self._third_party_invite_limiter = Ratelimiter( diff --git a/synapse/module_api/__init__.py b/synapse/module_api/__init__.py
index 3148957229..e36503a526 100644 --- a/synapse/module_api/__init__.py +++ b/synapse/module_api/__init__.py
@@ -94,6 +94,9 @@ from synapse.module_api.callbacks.media_repository_callbacks import ( GET_MEDIA_CONFIG_FOR_USER_CALLBACK, IS_USER_ALLOWED_TO_UPLOAD_MEDIA_OF_SIZE_CALLBACK, ) +from synapse.module_api.callbacks.ratelimit_callbacks import ( + GET_RATELIMIT_OVERRIDE_FOR_USER_CALLBACK, +) from synapse.module_api.callbacks.spamchecker_callbacks import ( CHECK_EVENT_FOR_SPAM_CALLBACK, CHECK_LOGIN_FOR_SPAM_CALLBACK, @@ -367,6 +370,20 @@ class ModuleApi: on_legacy_admin_request=on_legacy_admin_request, ) + def register_ratelimit_callbacks( + self, + *, + get_ratelimit_override_for_user: Optional[ + GET_RATELIMIT_OVERRIDE_FOR_USER_CALLBACK + ] = None, + ) -> None: + """Registers callbacks for ratelimit capabilities. + Added in Synapse v1.132.0. + """ + return self._callbacks.ratelimit.register_callbacks( + get_ratelimit_override_for_user=get_ratelimit_override_for_user, + ) + def register_media_repository_callbacks( self, *, @@ -376,7 +393,7 @@ class ModuleApi: ] = None, ) -> None: """Registers callbacks for media repository capabilities. - Added in Synapse v1.x.x. + Added in Synapse v1.132.0. """ return self._callbacks.media_repository.register_callbacks( get_media_config_for_user=get_media_config_for_user, diff --git a/synapse/module_api/callbacks/__init__.py b/synapse/module_api/callbacks/__init__.py
index a36c0fc7c6..16ef7a4b47 100644 --- a/synapse/module_api/callbacks/__init__.py +++ b/synapse/module_api/callbacks/__init__.py
@@ -30,6 +30,9 @@ from synapse.module_api.callbacks.account_validity_callbacks import ( from synapse.module_api.callbacks.media_repository_callbacks import ( MediaRepositoryModuleApiCallbacks, ) +from synapse.module_api.callbacks.ratelimit_callbacks import ( + RatelimitModuleApiCallbacks, +) from synapse.module_api.callbacks.spamchecker_callbacks import ( SpamCheckerModuleApiCallbacks, ) @@ -42,5 +45,6 @@ class ModuleApiCallbacks: def __init__(self, hs: "HomeServer") -> None: self.account_validity = AccountValidityModuleApiCallbacks() self.media_repository = MediaRepositoryModuleApiCallbacks(hs) + self.ratelimit = RatelimitModuleApiCallbacks(hs) self.spam_checker = SpamCheckerModuleApiCallbacks(hs) self.third_party_event_rules = ThirdPartyEventRulesModuleApiCallbacks(hs) diff --git a/synapse/module_api/callbacks/ratelimit_callbacks.py b/synapse/module_api/callbacks/ratelimit_callbacks.py new file mode 100644
index 0000000000..ced721b407 --- /dev/null +++ b/synapse/module_api/callbacks/ratelimit_callbacks.py
@@ -0,0 +1,62 @@ +# +# This file is licensed under the Affero General Public License (AGPL) version 3. +# +# Copyright (C) 2025 New Vector, Ltd +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# See the GNU Affero General Public License for more details: +# <https://www.gnu.org/licenses/agpl-3.0.html>. +# + +import logging +from typing import TYPE_CHECKING, Awaitable, Callable, List, Optional + +from synapse.storage.databases.main.room import RatelimitOverride +from synapse.util.async_helpers import delay_cancellation +from synapse.util.metrics import Measure + +if TYPE_CHECKING: + from synapse.server import HomeServer + +logger = logging.getLogger(__name__) + +GET_RATELIMIT_OVERRIDE_FOR_USER_CALLBACK = Callable[ + [str, str], Awaitable[Optional[RatelimitOverride]] +] + + +class RatelimitModuleApiCallbacks: + def __init__(self, hs: "HomeServer") -> None: + self.clock = hs.get_clock() + self._get_ratelimit_override_for_user_callbacks: List[ + GET_RATELIMIT_OVERRIDE_FOR_USER_CALLBACK + ] = [] + + def register_callbacks( + self, + get_ratelimit_override_for_user: Optional[ + GET_RATELIMIT_OVERRIDE_FOR_USER_CALLBACK + ] = None, + ) -> None: + """Register callbacks from module for each hook.""" + if get_ratelimit_override_for_user is not None: + self._get_ratelimit_override_for_user_callbacks.append( + get_ratelimit_override_for_user + ) + + async def get_ratelimit_override_for_user( + self, user_id: str, limiter_name: str + ) -> Optional[RatelimitOverride]: + for callback in self._get_ratelimit_override_for_user_callbacks: + with Measure(self.clock, f"{callback.__module__}.{callback.__qualname__}"): + res: Optional[RatelimitOverride] = await delay_cancellation( + callback(user_id, limiter_name) + ) + if res: + return res + + return None diff --git a/synapse/storage/databases/main/room.py b/synapse/storage/databases/main/room.py
index 56217fccdf..46491cc0fe 100644 --- a/synapse/storage/databases/main/room.py +++ b/synapse/storage/databases/main/room.py
@@ -77,7 +77,7 @@ logger = logging.getLogger(__name__) @attr.s(slots=True, frozen=True, auto_attribs=True) class RatelimitOverride: - messages_per_second: int + messages_per_second: float burst_count: int diff --git a/tests/api/test_ratelimiting.py b/tests/api/test_ratelimiting.py
index 1a1cbde74e..5e73f5d5ec 100644 --- a/tests/api/test_ratelimiting.py +++ b/tests/api/test_ratelimiting.py
@@ -1,6 +1,10 @@ +from typing import Optional + from synapse.api.ratelimiting import LimitExceededError, Ratelimiter from synapse.appservice import ApplicationService from synapse.config.ratelimiting import RatelimitSettings +from synapse.module_api.callbacks.ratelimit_callbacks import RatelimitModuleApiCallbacks +from synapse.storage.databases.main.room import RatelimitOverride from synapse.types import create_requester from tests import unittest @@ -440,3 +444,49 @@ class TestRatelimiter(unittest.HomeserverTestCase): limiter.can_do_action(requester=None, key="a", _time_now_s=20.0) ) self.assertTrue(success) + + def test_get_ratelimit_override_for_user_callback(self) -> None: + test_user_id = "@user:test" + test_limiter_name = "name" + callbacks = RatelimitModuleApiCallbacks(self.hs) + requester = create_requester(test_user_id) + limiter = Ratelimiter( + store=self.hs.get_datastores().main, + clock=self.clock, + cfg=RatelimitSettings( + test_limiter_name, + per_second=0.1, + burst_count=3, + ), + ratelimit_callbacks=callbacks, + ) + + # Observe four actions, exceeding the burst_count. + limiter.record_action(requester=requester, n_actions=4, _time_now_s=0.0) + + # We should be prevented from taking a new action now. + success, _ = self.get_success_or_raise( + limiter.can_do_action(requester=requester, _time_now_s=0.0) + ) + self.assertFalse(success) + + # Now register a callback that overrides the ratelimit for this user + # and limiter name. + async def get_ratelimit_override_for_user( + user_id: str, limiter_name: str + ) -> Optional[RatelimitOverride]: + if user_id == test_user_id: + return RatelimitOverride( + messages_per_second=0.1, + burst_count=10, + ) + return None + + callbacks.register_callbacks( + get_ratelimit_override_for_user=get_ratelimit_override_for_user + ) + + success, _ = self.get_success_or_raise( + limiter.can_do_action(requester=requester, _time_now_s=0.0) + ) + self.assertTrue(success)