summary refs log tree commit diff
diff options
context:
space:
mode:
authorBrendan Abolivier <babolivier@matrix.org>2021-07-22 17:50:07 +0200
committerGitHub <noreply@github.com>2021-07-22 17:50:07 +0200
commit1a1a83abcb767ec067492ef982b7264351ecc350 (patch)
treef68afe618e1e088b0d4ad8981b051c991a417f08
parentMerge pull request #99 from matrix-org/anoa/fix_pipeline (diff)
downloadsynapse-1a1a83abcb767ec067492ef982b7264351ecc350.tar.xz
Rework room freeze and implement unfreezing the room (#100)
Co-authored-by: Richard van der Hoff <1389908+richvdh@users.noreply.github.com>
-rw-r--r--synapse/handlers/message.py10
-rw-r--r--synapse/module_api/__init__.py2
-rw-r--r--synapse/third_party_rules/access_rules.py236
-rw-r--r--tests/rest/client/test_room_access_rules.py281
4 files changed, 308 insertions, 221 deletions
diff --git a/synapse/handlers/message.py b/synapse/handlers/message.py

index e06e8ff60c..67a8410276 100644 --- a/synapse/handlers/message.py +++ b/synapse/handlers/message.py
@@ -1414,11 +1414,13 @@ class EventCreationHandler: for k, v in original_event.internal_metadata.get_dict().items(): setattr(builder.internal_metadata, k, v) - # the event type hasn't changed, so there's no point in re-calculating the - # auth events. + # modules can send new state events, so we re-calculate the auth events just in + # case. + prev_event_ids = await self.store.get_prev_events_for_room(builder.room_id) + event = await builder.build( - prev_event_ids=original_event.prev_event_ids(), - auth_event_ids=original_event.auth_event_ids(), + prev_event_ids=prev_event_ids, + auth_event_ids=None, ) # we rebuild the event context, to be on the safe side. If nothing else, diff --git a/synapse/module_api/__init__.py b/synapse/module_api/__init__.py
index 781e02fbbb..2e38150eac 100644 --- a/synapse/module_api/__init__.py +++ b/synapse/module_api/__init__.py
@@ -43,7 +43,7 @@ class ModuleApi: can register new users etc if necessary. """ - def __init__(self, hs, auth_handler): + def __init__(self, hs: "HomeServer", auth_handler): self._hs = hs self._store = hs.get_datastore() diff --git a/synapse/third_party_rules/access_rules.py b/synapse/third_party_rules/access_rules.py
index a047699cc4..01d100c82d 100644 --- a/synapse/third_party_rules/access_rules.py +++ b/synapse/third_party_rules/access_rules.py
@@ -14,7 +14,7 @@ # limitations under the License. import email.utils import logging -from typing import Dict, List, Optional, Tuple +from typing import Dict, List, Optional, Tuple, Union from synapse.api.constants import EventTypes, JoinRules, Membership, RoomCreationPreset from synapse.api.errors import SynapseError @@ -22,10 +22,12 @@ from synapse.config._base import ConfigError from synapse.events import EventBase from synapse.module_api import ModuleApi from synapse.types import Requester, StateMap, UserID, get_domain_from_id +from synapse.util.frozenutils import unfreeze logger = logging.getLogger(__name__) ACCESS_RULES_TYPE = "im.vector.room.access_rules" +FROZEN_STATE_TYPE = "io.element.room.frozen" class AccessRules: @@ -108,7 +110,7 @@ class RoomAccessRules(object): ConfigError: If there was an issue with the provided module configuration. """ if "id_server" not in config: - raise ConfigError("No IS for event rules TchapEventRules") + raise ConfigError("No IS for event rules RoomAccessRules") return config @@ -320,7 +322,7 @@ class RoomAccessRules(object): self, event: EventBase, state_events: StateMap[EventBase], - ) -> bool: + ) -> Union[bool, dict]: """Implements synapse.events.ThirdPartyEventRules.check_event_allowed. Checks the event's type and the current rule and calls the right function to @@ -332,8 +334,18 @@ class RoomAccessRules(object): State events in the room the event originated from. Returns: - True if the event can be allowed, False otherwise. + True if the event should be allowed, False if it should be rejected, or a dictionary if the + event needs to be rebuilt (containing the event's new content). """ + if event.type == FROZEN_STATE_TYPE: + return await self._on_frozen_state_change(event, state_events) + + # If the room is frozen, we allow a very small number of events to go through + # (unfreezing, leaving, etc.). + frozen_state = state_events.get((FROZEN_STATE_TYPE, "")) + if frozen_state and frozen_state.content.get("frozen", False): + return await self._on_event_when_frozen(event, state_events) + if event.type == ACCESS_RULES_TYPE: return await self._on_rules_change(event, state_events) @@ -394,6 +406,129 @@ class RoomAccessRules(object): # published to the public rooms directory. return True + async def _on_event_when_frozen( + self, + event: EventBase, + state_events: StateMap[EventBase], + ) -> Union[bool, dict]: + """Check if the provided event is allowed when the room is frozen. + + The only events allowed are for a member to leave the room, and for the room to + be (un)frozen. In the latter case, also attempt to unfreeze the room. + + + Args: + event: The event to allow or deny. + state_events: A dict mapping (event type, state key) to state event. + State events in the room before the event was sent. + Returns: + A boolean indicating whether the event is allowed, or a dict if the event is + allowed but the state of the room has been modified (i.e. the room has been + unfrozen). This is because returning a dict of the event forces Synapse to + rebuild it, which is needed if the state of the room has changed. + """ + # Allow users to leave the room; don't allow kicks though. + if ( + event.type == EventTypes.Member + and event.membership == Membership.LEAVE + and event.sender == event.state_key + ): + return True + + if event.type == EventTypes.PowerLevels: + # Check if the power level event is associated with a room unfreeze (because + # the power level events will be sent before the frozen state event). This + # means we check that the users_default is back to 0 and the sender set + # themselves as admin. + current_power_levels = state_events.get((EventTypes.PowerLevels, "")) + if current_power_levels: + old_content = current_power_levels.content.copy() + old_content["users_default"] = 0 + + new_content = unfreeze(event.content) + sender_pl = new_content.get("users", {}).get(event.sender, 0) + + # We don't care about the users section as long as the new event gives + # full power to the sender. + del old_content["users"] + del new_content["users"] + + if new_content == old_content and sender_pl == 100: + return True + + return False + + async def _on_frozen_state_change( + self, + event: EventBase, + state_events: StateMap[EventBase], + ) -> Union[bool, dict]: + frozen = event.content.get("frozen", None) + if not isinstance(frozen, bool): + # Invalid event: frozen is either missing or not a boolean. + return False + + # If the event was sent from a restricted homeserver, don't allow the state + # change. + if ( + UserID.from_string(event.sender).domain + in self.domains_forbidden_when_restricted + ): + return False + + current_frozen_state = state_events.get( + (FROZEN_STATE_TYPE, ""), + ) # type: EventBase + + if ( + current_frozen_state is not None + and current_frozen_state.content.get("frozen") == frozen + ): + # This is a noop, accept the new event but don't do anything more. + return True + + # If the event was received over federation, we want to accept it but not to + # change the power levels. + if not self._is_local_user(event.sender): + return True + + current_power_levels = state_events.get( + (EventTypes.PowerLevels, ""), + ) # type: EventBase + + power_levels_content = unfreeze(current_power_levels.content) + + if not frozen: + # We're unfreezing the room: enforce the right value for the power levels so + # the room isn't in a weird/broken state afterwards. + users = power_levels_content.setdefault("users", {}) + users[event.sender] = 100 + power_levels_content["users_default"] = 0 + else: + # Send a new power levels event with a similar content to the previous one + # except users_default is 100 to allow any user to unfreeze the room. + power_levels_content["users_default"] = 100 + + # Just to be safe, also delete all users that don't have a power level of + # 100, in order to prevent anyone from being unable to unfreeze the room. + users = {} + for user, level in power_levels_content["users"].items(): + if level == 100: + users[user] = level + power_levels_content["users"] = users + + await self.module_api.create_and_send_event_into_room( + { + "room_id": event.room_id, + "sender": event.sender, + "type": EventTypes.PowerLevels, + "content": power_levels_content, + "state_key": "", + } + ) + + return event.get_dict() + async def _on_rules_change( self, event: EventBase, state_events: StateMap[EventBase] ): @@ -448,7 +583,7 @@ class RoomAccessRules(object): event: EventBase, rule: str, state_events: StateMap[EventBase], - ) -> bool: + ) -> Union[bool, dict]: """Applies the correct rule for incoming m.room.member and m.room.third_party_invite events. @@ -459,7 +594,10 @@ class RoomAccessRules(object): The state of the room before the event was sent. Returns: - True if the event can be allowed, False otherwise. + A boolean indicating whether the event is allowed, or a dict if the event is + allowed but the state of the room has been modified (i.e. the room has been + frozen). This is because returning a dict of the event forces Synapse to + rebuild it, which is needed if the state of the room has changed. """ if rule == AccessRules.RESTRICTED: ret = self._on_membership_or_invite_restricted(event) @@ -472,7 +610,7 @@ class RoomAccessRules(object): # might want to change that in the future. ret = self._on_membership_or_invite_restricted(event) - if event.type == "m.room.member": + if event.type == EventTypes.Member: # If this is an admin leaving, and they are the last admin in the room, # raise the power levels of the room so that the room is 'frozen'. # @@ -484,6 +622,9 @@ class RoomAccessRules(object): and event.membership == Membership.LEAVE ): await self._freeze_room_if_last_admin_is_leaving(event, state_events) + if ret: + # Return an event dict to force Synapse into rebuilding the event. + return event.get_dict() return ret @@ -535,88 +676,13 @@ class RoomAccessRules(object): # Freeze the room by raising the required power level to send events to 100 logger.info("Freezing room '%s'", event.room_id) - # Modify the existing power levels to raise all required types to 100 - # - # This changes a power level state event's content from something like: - # { - # "redact": 50, - # "state_default": 50, - # "ban": 50, - # "notifications": { - # "room": 50 - # }, - # "events": { - # "m.room.avatar": 50, - # "m.room.encryption": 50, - # "m.room.canonical_alias": 50, - # "m.room.name": 50, - # "im.vector.modular.widgets": 50, - # "m.room.topic": 50, - # "m.room.tombstone": 50, - # "m.room.history_visibility": 100, - # "m.room.power_levels": 100 - # }, - # "users_default": 0, - # "events_default": 0, - # "users": { - # "@admin:example.com": 100, - # }, - # "kick": 50, - # "invite": 0 - # } - # - # to - # - # { - # "redact": 100, - # "state_default": 100, - # "ban": 100, - # "notifications": { - # "room": 50 - # }, - # "events": {} - # "users_default": 0, - # "events_default": 100, - # "users": { - # "@admin:example.com": 100, - # }, - # "kick": 100, - # "invite": 100 - # } - new_content = {} - for key, value in power_level_content.items(): - # Do not change "users_default", as that key specifies the default power - # level of new users - if isinstance(value, int) and key != "users_default": - value = 100 - new_content[key] = value - - # Set some values in case they are missing from the original - # power levels event content - new_content.update( - { - # Clear out any special-cased event keys - "events": {}, - # Ensure state_default and events_default keys exist and are 100. - # Otherwise a lower PL user could potentially send state events that - # aren't explicitly mentioned elsewhere in the power level dict - "state_default": 100, - "events_default": 100, - # Membership events default to 50 if they aren't present. Set them - # to 100 here, as they would be set to 100 if they were present anyways - "ban": 100, - "kick": 100, - "invite": 100, - "redact": 100, - } - ) - + # Mark the room as frozen await self.module_api.create_and_send_event_into_room( { "room_id": event.room_id, "sender": user_id, - "type": EventTypes.PowerLevels, - "content": new_content, + "type": FROZEN_STATE_TYPE, + "content": {"frozen": True}, "state_key": "", } ) diff --git a/tests/rest/client/test_room_access_rules.py b/tests/rest/client/test_room_access_rules.py
index 6344fcdf6a..64744b7176 100644 --- a/tests/rest/client/test_room_access_rules.py +++ b/tests/rest/client/test_room_access_rules.py
@@ -15,7 +15,6 @@ import json import random import string -from typing import Optional from mock import Mock @@ -27,10 +26,11 @@ from synapse.rest import admin from synapse.rest.client.v1 import directory, login, room from synapse.third_party_rules.access_rules import ( ACCESS_RULES_TYPE, + FROZEN_STATE_TYPE, AccessRules, RoomAccessRules, ) -from synapse.types import JsonDict, create_requester +from synapse.types import create_requester from tests import unittest @@ -851,155 +851,170 @@ class RoomAccessTestCase(unittest.HomeserverTestCase): ) ) - def test_freezing_a_room(self): - """Tests that the power levels in a room change to prevent new events from - non-admin users when the last admin of a room leaves. + def test_frozen_update_power_levels(self): + """Tests that freezing the room with a io.element.room.frozen event produces the + correct changes to the power levels. """ + room_id = self.create_room() - def freeze_room_with_id_and_power_levels( - room_id: str, - custom_power_levels_content: Optional[JsonDict] = None, - ): - # Invite a user to the room, they join with PL 0 - self.helper.invite( - room=room_id, - src=self.user_id, - targ=self.invitee_id, - tok=self.tok, - ) + # Set a user with a non-default PL in the room so we can make sure it's correctly + # removed when freezing the room, so that users can't be prevented from unfreezing + # the room by others. + power_levels = self.helper.get_state( + room_id=room_id, + event_type=EventTypes.PowerLevels, + tok=self.tok, + ) - # Invitee joins the room - self.helper.join( - room=room_id, - user=self.invitee_id, - tok=self.invitee_tok, - ) + power_levels["users"]["@fakeuser:test"] = 50 - if not custom_power_levels_content: - # Retrieve the room's current power levels event content - power_levels = self.helper.get_state( - room_id=room_id, - event_type="m.room.power_levels", - tok=self.tok, - ) - else: - power_levels = custom_power_levels_content - - # Override the room's power levels with the given power levels content - self.helper.send_state( - room_id=room_id, - event_type="m.room.power_levels", - body=custom_power_levels_content, - tok=self.tok, - ) - - # Ensure that the invitee leaving the room does not change the power levels - self.helper.leave( - room=room_id, - user=self.invitee_id, - tok=self.invitee_tok, - ) + self.helper.send_state( + room_id=room_id, + event_type=EventTypes.PowerLevels, + body=power_levels, + tok=self.tok, + ) - # Retrieve the new power levels of the room - new_power_levels = self.helper.get_state( - room_id=room_id, - event_type="m.room.power_levels", - tok=self.tok, - ) + # Mark the room as frozen. + self.helper.send_state( + room_id=room_id, + event_type=FROZEN_STATE_TYPE, + body={"frozen": True}, + tok=self.tok, + ) + + # Check that the right power levels event was set as a result. + power_levels = self.helper.get_state( + room_id=room_id, + event_type=EventTypes.PowerLevels, + tok=self.tok, + ) - # Ensure they have not changed - self.assertDictEqual(power_levels, new_power_levels) + self.assertEqual(power_levels["users_default"], 100) + for user, level in power_levels["users"].items(): + self.assertEqual(level, 100, user) - # Invite the user back again - self.helper.invite( - room=room_id, - src=self.user_id, - targ=self.invitee_id, - tok=self.tok, - ) + # Unfreeze the room. + self.helper.send_state( + room_id=room_id, + event_type=FROZEN_STATE_TYPE, + body={"frozen": False}, + tok=self.tok, + ) + + # Check that the right power levels event was set as a result. + power_levels = self.helper.get_state( + room_id=room_id, + event_type=EventTypes.PowerLevels, + tok=self.tok, + ) + + self.assertEqual(power_levels["users_default"], 0) + self.assertEqual(power_levels.get("users", {}).get(self.user_id, 0), 100) - # Invitee joins the room - self.helper.join( - room=room_id, - user=self.invitee_id, - tok=self.invitee_tok, + def test_freezing_no_send(self): + """Tests that freezing a room prevents most new events from being sent into it.""" + room_id = self.create_room() + + def _send_event_and_pl_update(expected_code: int): + # Check whether we can send events. In a frozen state this should be + # forbidden. + self.helper.send_event( + room_id=room_id, + type=EventTypes.Message, + content={"msgtype": "m.text", "body": "hello world"}, + tok=self.tok, + expect_code=expected_code, ) - # Now the admin leaves the room - self.helper.leave( - room=room_id, - user=self.user_id, + # Check whether we can update the power levels in a way that would explicitly + # prevent someone from unfreezing the room. In a frozen state this should be + # forbidden. + power_levels = self.helper.get_state( + room_id=room_id, + event_type=EventTypes.PowerLevels, tok=self.tok, ) + power_levels["users"]["@fakeuser:test"] = 50 - # Check the power levels again - new_power_levels = self.helper.get_state( + self.helper.send_state( room_id=room_id, - event_type="m.room.power_levels", - tok=self.invitee_tok, + event_type=EventTypes.PowerLevels, + body=power_levels, + tok=self.tok, + expect_code=expected_code, ) - # Ensure that the new power levels prevent anyone but admins from sending - # certain events - self.assertEquals(new_power_levels["state_default"], 100) - self.assertEquals(new_power_levels["events_default"], 100) - self.assertEquals(new_power_levels["kick"], 100) - self.assertEquals(new_power_levels["invite"], 100) - self.assertEquals(new_power_levels["ban"], 100) - self.assertEquals(new_power_levels["redact"], 100) - self.assertDictEqual(new_power_levels["events"], {}) - self.assertDictEqual(new_power_levels["users"], {self.user_id: 100}) - - # Ensure new users entering the room aren't going to immediately become admins - self.assertEquals(new_power_levels["users_default"], 0) - - # Test that freezing a room with the default power level state event content works - room1 = self.create_room() - freeze_room_with_id_and_power_levels(room1) - - # Test that freezing a room with a power level state event that is missing - # `state_default` and `event_default` keys behaves as expected - room2 = self.create_room() - freeze_room_with_id_and_power_levels( - room2, - { - "ban": 50, - "events": { - "m.room.avatar": 50, - "m.room.canonical_alias": 50, - "m.room.history_visibility": 100, - "m.room.name": 50, - "m.room.power_levels": 100, - }, - "invite": 0, - "kick": 50, - "redact": 50, - "users": {self.user_id: 100}, - "users_default": 0, - # Explicitly remove `state_default` and `event_default` keys - }, + # Freeze the room and check that we can't send events into it. + self.helper.send_state( + room_id=room_id, + event_type=FROZEN_STATE_TYPE, + body={"frozen": True}, + tok=self.tok, ) - # Test that freezing a room with a power level state event that is *additionally* - # missing `ban`, `invite`, `kick` and `redact` keys behaves as expected - room3 = self.create_room() - freeze_room_with_id_and_power_levels( - room3, - { - "events": { - "m.room.avatar": 50, - "m.room.canonical_alias": 50, - "m.room.history_visibility": 100, - "m.room.name": 50, - "m.room.power_levels": 100, - }, - "users": {self.user_id: 100}, - "users_default": 0, - # Explicitly remove `state_default` and `event_default` keys - # Explicitly remove `ban`, `invite`, `kick` and `redact` keys - }, + _send_event_and_pl_update(403) + + # Unfreeze the room and check that we can send events into it now. + self.helper.send_state( + room_id=room_id, + event_type=FROZEN_STATE_TYPE, + body={"frozen": False}, + tok=self.tok, + ) + + _send_event_and_pl_update(200) + + def test_auto_freeze_and_unfreeze(self): + room_id = self.create_room( + preset=RoomCreationPreset.PRIVATE_CHAT, + invite=[self.invitee_id], + ) + + self.helper.join( + room=room_id, + user=self.invitee_id, + tok=self.invitee_tok, + ) + + self.helper.leave( + room=room_id, + user=self.user_id, + tok=self.tok, ) + frozen = self.helper.get_state( + room_id=room_id, + event_type=FROZEN_STATE_TYPE, + tok=self.invitee_tok, + ) + + self.assertTrue(frozen["frozen"]) + + power_levels = self.helper.get_state( + room_id=room_id, + event_type=EventTypes.PowerLevels, + tok=self.invitee_tok, + ) + + self.assertEqual(power_levels["users_default"], 100) + + self.helper.send_state( + room_id=room_id, + event_type=FROZEN_STATE_TYPE, + body={"frozen": False}, + tok=self.invitee_tok, + ) + + power_levels = self.helper.get_state( + room_id=room_id, + event_type=EventTypes.PowerLevels, + tok=self.invitee_tok, + ) + + self.assertEqual(power_levels["users_default"], 0) + self.assertEqual(power_levels["users"].get(self.invitee_id, 0), 100) + def create_room( self, direct=False, @@ -1007,6 +1022,7 @@ class RoomAccessTestCase(unittest.HomeserverTestCase): preset=RoomCreationPreset.TRUSTED_PRIVATE_CHAT, initial_state=None, power_levels_content_override=None, + invite=[], expected_code=200, ): content = {"is_direct": direct, "preset": preset} @@ -1025,6 +1041,9 @@ class RoomAccessTestCase(unittest.HomeserverTestCase): if power_levels_content_override: content["power_levels_content_override"] = power_levels_content_override + if invite: + content["invite"] = invite + channel = self.make_request( "POST", "/_matrix/client/r0/createRoom",