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",
|