Allow ThirdPartyEventRules modules to manipulate public room state (#8292)

This PR allows `ThirdPartyEventRules` modules to view, manipulate and block changes to the state of whether a room is published in the public rooms directory.

While the idea of whether a room is in the public rooms list is not kept within an event in the room, `ThirdPartyEventRules` generally deal with controlling which modifications can happen to a room. Public rooms fits within that idea, even if its toggle state isn't controlled through a state event.
This commit is contained in:
Andrew Morgan 2020-10-05 14:57:46 +01:00 committed by GitHub
parent f31f8e6319
commit 0991a2da93
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 223 additions and 19 deletions

View File

@ -75,6 +75,23 @@ for example:
wget https://packages.matrix.org/debian/pool/main/m/matrix-synapse-py3/matrix-synapse-py3_1.3.0+stretch1_amd64.deb wget https://packages.matrix.org/debian/pool/main/m/matrix-synapse-py3/matrix-synapse-py3_1.3.0+stretch1_amd64.deb
dpkg -i matrix-synapse-py3_1.3.0+stretch1_amd64.deb dpkg -i matrix-synapse-py3_1.3.0+stretch1_amd64.deb
Upgrading to v1.22.0
====================
ThirdPartyEventRules breaking changes
-------------------------------------
This release introduces a backwards-incompatible change to modules making use of
``ThirdPartyEventRules`` in Synapse. If you make use of a module defined under the
``third_party_event_rules`` config option, please make sure it is updated to handle
the below change:
The ``http_client`` argument is no longer passed to modules as they are initialised. Instead,
modules are expected to make use of the ``http_client`` property on the ``ModuleApi`` class.
Modules are now passed a ``module_api`` argument during initialisation, which is an instance of
``ModuleApi``. ``ModuleApi`` instances have a ``http_client`` property which acts the same as
the ``http_client`` argument previously passed to ``ThirdPartyEventRules`` modules.
Upgrading to v1.21.0 Upgrading to v1.21.0
==================== ====================

1
changelog.d/8292.feature Normal file
View File

@ -0,0 +1 @@
Allow `ThirdPartyEventRules` modules to query and manipulate whether a room is in the public rooms directory.

View File

@ -12,10 +12,12 @@
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and # See the License for the specific language governing permissions and
# limitations under the License. # limitations under the License.
from typing import Callable
from synapse.events import EventBase from synapse.events import EventBase
from synapse.events.snapshot import EventContext from synapse.events.snapshot import EventContext
from synapse.types import Requester from synapse.module_api import ModuleApi
from synapse.types import Requester, StateMap
class ThirdPartyEventRules: class ThirdPartyEventRules:
@ -38,7 +40,7 @@ class ThirdPartyEventRules:
if module is not None: if module is not None:
self.third_party_rules = module( self.third_party_rules = module(
config=config, http_client=hs.get_simple_http_client() config=config, module_api=ModuleApi(hs, hs.get_auth_handler()),
) )
async def check_event_allowed( async def check_event_allowed(
@ -106,6 +108,46 @@ class ThirdPartyEventRules:
if self.third_party_rules is None: if self.third_party_rules is None:
return True return True
state_events = await self._get_state_map_for_room(room_id)
ret = await self.third_party_rules.check_threepid_can_be_invited(
medium, address, state_events
)
return ret
async def check_visibility_can_be_modified(
self, room_id: str, new_visibility: str
) -> bool:
"""Check if a room is allowed to be published to, or removed from, the public room
list.
Args:
room_id: The ID of the room.
new_visibility: The new visibility state. Either "public" or "private".
Returns:
True if the room's visibility can be modified, False if not.
"""
if self.third_party_rules is None:
return True
check_func = getattr(self.third_party_rules, "check_visibility_can_be_modified")
if not check_func or not isinstance(check_func, Callable):
return True
state_events = await self._get_state_map_for_room(room_id)
return await check_func(room_id, state_events, new_visibility)
async def _get_state_map_for_room(self, room_id: str) -> StateMap[EventBase]:
"""Given a room ID, return the state events of that room.
Args:
room_id: The ID of the room.
Returns:
A dict mapping (event type, state key) to state event.
"""
state_ids = await self.store.get_filtered_current_state_ids(room_id) state_ids = await self.store.get_filtered_current_state_ids(room_id)
room_state_events = await self.store.get_events(state_ids.values()) room_state_events = await self.store.get_events(state_ids.values())
@ -113,7 +155,4 @@ class ThirdPartyEventRules:
for key, event_id in state_ids.items(): for key, event_id in state_ids.items():
state_events[key] = room_state_events[event_id] state_events[key] = room_state_events[event_id]
ret = await self.third_party_rules.check_threepid_can_be_invited( return state_events
medium, address, state_events
)
return ret

View File

@ -46,6 +46,7 @@ class DirectoryHandler(BaseHandler):
self.config = hs.config self.config = hs.config
self.enable_room_list_search = hs.config.enable_room_list_search self.enable_room_list_search = hs.config.enable_room_list_search
self.require_membership = hs.config.require_membership_for_aliases self.require_membership = hs.config.require_membership_for_aliases
self.third_party_event_rules = hs.get_third_party_event_rules()
self.federation = hs.get_federation_client() self.federation = hs.get_federation_client()
hs.get_federation_registry().register_query_handler( hs.get_federation_registry().register_query_handler(
@ -454,6 +455,15 @@ class DirectoryHandler(BaseHandler):
# per alias creation rule? # per alias creation rule?
raise SynapseError(403, "Not allowed to publish room") raise SynapseError(403, "Not allowed to publish room")
# Check if publishing is blocked by a third party module
allowed_by_third_party_rules = await (
self.third_party_event_rules.check_visibility_can_be_modified(
room_id, visibility
)
)
if not allowed_by_third_party_rules:
raise SynapseError(403, "Not allowed to publish room")
await self.store.set_room_is_public(room_id, making_public) await self.store.set_room_is_public(room_id, making_public)
async def edit_published_appservice_room_list( async def edit_published_appservice_room_list(

View File

@ -681,6 +681,15 @@ class RoomCreationHandler(BaseHandler):
creator_id=user_id, is_public=is_public, room_version=room_version, creator_id=user_id, is_public=is_public, room_version=room_version,
) )
# Check whether this visibility value is blocked by a third party module
allowed_by_third_party_rules = await (
self.third_party_event_rules.check_visibility_can_be_modified(
room_id, visibility
)
)
if not allowed_by_third_party_rules:
raise SynapseError(403, "Room visibility value not allowed.")
directory_handler = self.hs.get_handlers().directory_handler directory_handler = self.hs.get_handlers().directory_handler
if room_alias: if room_alias:
await directory_handler.create_association( await directory_handler.create_association(

View File

@ -14,13 +14,18 @@
# See the License for the specific language governing permissions and # See the License for the specific language governing permissions and
# limitations under the License. # limitations under the License.
import logging import logging
from typing import TYPE_CHECKING
from twisted.internet import defer from twisted.internet import defer
from synapse.http.client import SimpleHttpClient
from synapse.http.site import SynapseRequest from synapse.http.site import SynapseRequest
from synapse.logging.context import make_deferred_yieldable, run_in_background from synapse.logging.context import make_deferred_yieldable, run_in_background
from synapse.types import UserID from synapse.types import UserID
if TYPE_CHECKING:
from synapse.server import HomeServer
""" """
This package defines the 'stable' API which can be used by extension modules which This package defines the 'stable' API which can be used by extension modules which
are loaded into Synapse. are loaded into Synapse.
@ -43,6 +48,27 @@ class ModuleApi:
self._auth = hs.get_auth() self._auth = hs.get_auth()
self._auth_handler = auth_handler self._auth_handler = auth_handler
# We expose these as properties below in order to attach a helpful docstring.
self._http_client = hs.get_simple_http_client() # type: SimpleHttpClient
self._public_room_list_manager = PublicRoomListManager(hs)
@property
def http_client(self):
"""Allows making outbound HTTP requests to remote resources.
An instance of synapse.http.client.SimpleHttpClient
"""
return self._http_client
@property
def public_room_list_manager(self):
"""Allows adding to, removing from and checking the status of rooms in the
public room list.
An instance of synapse.module_api.PublicRoomListManager
"""
return self._public_room_list_manager
def get_user_by_req(self, req, allow_guest=False): def get_user_by_req(self, req, allow_guest=False):
"""Check the access_token provided for a request """Check the access_token provided for a request
@ -266,3 +292,44 @@ class ModuleApi:
await self._auth_handler.complete_sso_login( await self._auth_handler.complete_sso_login(
registered_user_id, request, client_redirect_url, registered_user_id, request, client_redirect_url,
) )
class PublicRoomListManager:
"""Contains methods for adding to, removing from and querying whether a room
is in the public room list.
"""
def __init__(self, hs: "HomeServer"):
self._store = hs.get_datastore()
async def room_is_in_public_room_list(self, room_id: str) -> bool:
"""Checks whether a room is in the public room list.
Args:
room_id: The ID of the room.
Returns:
Whether the room is in the public room list. Returns False if the room does
not exist.
"""
room = await self._store.get_room(room_id)
if not room:
return False
return room.get("is_public", False)
async def add_room_to_public_room_list(self, room_id: str) -> None:
"""Publishes a room to the public room list.
Args:
room_id: The ID of the room.
"""
await self._store.set_room_is_public(room_id, True)
async def remove_room_from_public_room_list(self, room_id: str) -> None:
"""Removes a room from the public room list.
Args:
room_id: The ID of the room.
"""
await self._store.set_room_is_public(room_id, False)

View File

@ -12,13 +12,20 @@
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and # See the License for the specific language governing permissions and
# limitations under the License. # limitations under the License.
from synapse.module_api import ModuleApi from synapse.module_api import ModuleApi
from synapse.rest import admin
from synapse.rest.client.v1 import login, room
from tests.unittest import HomeserverTestCase from tests.unittest import HomeserverTestCase
class ModuleApiTestCase(HomeserverTestCase): class ModuleApiTestCase(HomeserverTestCase):
servlets = [
admin.register_servlets,
login.register_servlets,
room.register_servlets,
]
def prepare(self, reactor, clock, homeserver): def prepare(self, reactor, clock, homeserver):
self.store = homeserver.get_datastore() self.store = homeserver.get_datastore()
self.module_api = ModuleApi(homeserver, homeserver.get_auth_handler()) self.module_api = ModuleApi(homeserver, homeserver.get_auth_handler())
@ -52,3 +59,50 @@ class ModuleApiTestCase(HomeserverTestCase):
# Check that the displayname was assigned # Check that the displayname was assigned
displayname = self.get_success(self.store.get_profile_displayname("bob")) displayname = self.get_success(self.store.get_profile_displayname("bob"))
self.assertEqual(displayname, "Bobberino") self.assertEqual(displayname, "Bobberino")
def test_public_rooms(self):
"""Tests that a room can be added and removed from the public rooms list,
as well as have its public rooms directory state queried.
"""
# Create a user and room to play with
user_id = self.register_user("kermit", "monkey")
tok = self.login("kermit", "monkey")
room_id = self.helper.create_room_as(user_id, tok=tok)
# The room should not currently be in the public rooms directory
is_in_public_rooms = self.get_success(
self.module_api.public_room_list_manager.room_is_in_public_room_list(
room_id
)
)
self.assertFalse(is_in_public_rooms)
# Let's try adding it to the public rooms directory
self.get_success(
self.module_api.public_room_list_manager.add_room_to_public_room_list(
room_id
)
)
# And checking whether it's in there...
is_in_public_rooms = self.get_success(
self.module_api.public_room_list_manager.room_is_in_public_room_list(
room_id
)
)
self.assertTrue(is_in_public_rooms)
# Let's remove it again
self.get_success(
self.module_api.public_room_list_manager.remove_room_from_public_room_list(
room_id
)
)
# Should be gone
is_in_public_rooms = self.get_success(
self.module_api.public_room_list_manager.room_is_in_public_room_list(
room_id
)
)
self.assertFalse(is_in_public_rooms)

View File

@ -12,18 +12,23 @@
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and # See the License for the specific language governing permissions and
# limitations under the License. # limitations under the License.
from synapse.rest import admin from synapse.rest import admin
from synapse.rest.client.v1 import login, room from synapse.rest.client.v1 import login, room
from synapse.types import Requester
from tests import unittest from tests import unittest
class ThirdPartyRulesTestModule: class ThirdPartyRulesTestModule:
def __init__(self, config): def __init__(self, config, *args, **kwargs):
pass pass
def check_event_allowed(self, event, context): async def on_create_room(
self, requester: Requester, config: dict, is_requester_admin: bool
):
return True
async def check_event_allowed(self, event, context):
if event.type == "foo.bar.forbidden": if event.type == "foo.bar.forbidden":
return False return False
else: else:
@ -51,29 +56,31 @@ class ThirdPartyRulesTestCase(unittest.HomeserverTestCase):
self.hs = self.setup_test_homeserver(config=config) self.hs = self.setup_test_homeserver(config=config)
return self.hs return self.hs
def prepare(self, reactor, clock, homeserver):
# Create a user and room to play with during the tests
self.user_id = self.register_user("kermit", "monkey")
self.tok = self.login("kermit", "monkey")
self.room_id = self.helper.create_room_as(self.user_id, tok=self.tok)
def test_third_party_rules(self): def test_third_party_rules(self):
"""Tests that a forbidden event is forbidden from being sent, but an allowed one """Tests that a forbidden event is forbidden from being sent, but an allowed one
can be sent. can be sent.
""" """
user_id = self.register_user("kermit", "monkey")
tok = self.login("kermit", "monkey")
room_id = self.helper.create_room_as(user_id, tok=tok)
request, channel = self.make_request( request, channel = self.make_request(
"PUT", "PUT",
"/_matrix/client/r0/rooms/%s/send/foo.bar.allowed/1" % room_id, "/_matrix/client/r0/rooms/%s/send/foo.bar.allowed/1" % self.room_id,
{}, {},
access_token=tok, access_token=self.tok,
) )
self.render(request) self.render(request)
self.assertEquals(channel.result["code"], b"200", channel.result) self.assertEquals(channel.result["code"], b"200", channel.result)
request, channel = self.make_request( request, channel = self.make_request(
"PUT", "PUT",
"/_matrix/client/r0/rooms/%s/send/foo.bar.forbidden/1" % room_id, "/_matrix/client/r0/rooms/%s/send/foo.bar.forbidden/1" % self.room_id,
{}, {},
access_token=tok, access_token=self.tok,
) )
self.render(request) self.render(request)
self.assertEquals(channel.result["code"], b"403", channel.result) self.assertEquals(channel.result["code"], b"403", channel.result)