Skip to content
This repository has been archived by the owner on Apr 12, 2024. It is now read-only.

"Freeze" a room when the last admin of that room leaves #59

Merged
merged 13 commits into from
Oct 13, 2020
Merged
1 change: 1 addition & 0 deletions changelog.d/59.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Freeze a room when the last administrator in the room leaves.
174 changes: 171 additions & 3 deletions synapse/third_party_rules/access_rules.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
# See the License for the specific language governing permissions and
# limitations under the License.
import email.utils
import logging
from typing import Dict, List, Optional, Tuple

from twisted.internet import defer
Expand All @@ -22,7 +23,9 @@
from synapse.config._base import ConfigError
from synapse.events import EventBase
from synapse.module_api import ModuleApi
from synapse.types import Requester, StateMap, get_domain_from_id
from synapse.types import Requester, StateMap, UserID, get_domain_from_id

logger = logging.getLogger(__name__)

ACCESS_RULES_TYPE = "im.vector.room.access_rules"

Expand Down Expand Up @@ -323,7 +326,7 @@ async def check_event_allowed(
)

if event.type == EventTypes.Member or event.type == EventTypes.ThirdPartyInvite:
return self._on_membership_or_invite(event, rule, state_events)
return await self._on_membership_or_invite(event, rule, state_events)

if event.type == EventTypes.JoinRules:
return self._on_join_rule_change(event, rule)
Expand Down Expand Up @@ -420,7 +423,7 @@ async def _on_rules_change(
prev_rule == AccessRules.RESTRICTED and new_rule == AccessRules.UNRESTRICTED
)

def _on_membership_or_invite(
async def _on_membership_or_invite(
self, event: EventBase, rule: str, state_events: StateMap[EventBase],
) -> bool:
"""Applies the correct rule for incoming m.room.member and
Expand All @@ -446,8 +449,154 @@ def _on_membership_or_invite(
# might want to change that in the future.
ret = self._on_membership_or_invite_restricted(event)

if event.type == "m.room.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'.
#
# We have to freeze the room by puppeting an admin user, which we can
# only do for local users
if (
self._is_local_user(event.sender)
and event.membership == Membership.LEAVE
):
await self._freeze_room_if_last_admin_is_leaving(event, state_events)

return ret

async def _freeze_room_if_last_admin_is_leaving(
self, event: EventBase, state_events: StateMap[EventBase]
):
power_level_state_event = state_events.get(
(EventTypes.PowerLevels, "")
) # type: EventBase
if not power_level_state_event:
return
power_level_content = power_level_state_event.content

# Do some validation checks on the power level state event
if (
not isinstance(power_level_content, dict)
or "users" not in power_level_content
or not isinstance(power_level_content["users"], dict)
):
# We can't use this power level event to determine whether the room should be
# frozen. Bail out.
return

user_id = event.get("sender")
if not user_id:
return

# Get every admin user defined in the room's state
admin_users = {
user
for user, power_level in power_level_content["users"].items()
anoadragon453 marked this conversation as resolved.
Show resolved Hide resolved
if power_level >= 100
richvdh marked this conversation as resolved.
Show resolved Hide resolved
}

if user_id not in admin_users:
# This user is not an admin, ignore them
return

if any(
event_type == EventTypes.Member
and event.membership in [Membership.JOIN, Membership.INVITE]
and state_key in admin_users
and state_key != user_id
for (event_type, state_key), event in state_events.items()
):
# There's another admin user in, or invited to, the room
return

# 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,
}
)

await self.module_api.create_and_send_event_into_room(
{
"room_id": event.room_id,
"sender": user_id,
"type": EventTypes.PowerLevels,
"content": new_content,
"state_key": "",
}
)

def _on_membership_or_invite_restricted(self, event: EventBase) -> bool:
"""Implements the checks and behaviour specified for the "restricted" rule.

Expand Down Expand Up @@ -753,6 +902,25 @@ def _is_invite_from_threepid(invite: EventBase, threepid_invite_token: str) -> b

return token == threepid_invite_token

def _is_local_user(self, user_id: str) -> bool:
"""Checks whether a given user ID belongs to this homeserver, or a remote

Args:
user_id: A user ID to check.

Returns:
True if the user belongs to this homeserver, False otherwise.
"""
user = UserID.from_string(user_id)

# Extract the localpart and ask the module API for a user ID from the localpart
# The module API will append the local homeserver's server_name
local_user_id = self.module_api.get_qualified_user_id(user.localpart)

# If the user ID we get based on the localpart is the same as the original user ID,
# then they were a local user
return user_id == local_user_id

def _user_is_invited_to_room(
self, user_id: str, state_events: StateMap[EventBase]
) -> bool:
Expand Down
131 changes: 130 additions & 1 deletion tests/rest/client/test_room_access_rules.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
import json
import random
import string
from typing import Optional

from mock import Mock

Expand All @@ -28,7 +29,7 @@
AccessRules,
RoomAccessRules,
)
from synapse.types import create_requester
from synapse.types import JsonDict, create_requester

from tests import unittest

Expand Down Expand Up @@ -840,6 +841,134 @@ def test_check_event_allowed(self):
)
self.assertTrue(can_join)

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

# Invitee joins the room
self.helper.join(
room=room_id, user=self.invitee_id, tok=self.invitee_tok,
)

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

# 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,
)

# Ensure they have not changed
self.assertDictEqual(power_levels, new_power_levels)

# Invite the user back again
self.helper.invite(
room=room_id, src=self.user_id, targ=self.invitee_id, tok=self.tok,
)

# Invitee joins the room
self.helper.join(
room=room_id, user=self.invitee_id, tok=self.invitee_tok,
)

# Now the admin leaves the room
self.helper.leave(
room=room_id, user=self.user_id, tok=self.tok,
)

# Check the power levels again
new_power_levels = self.helper.get_state(
room_id=room_id, event_type="m.room.power_levels", tok=self.invitee_tok,
)

# 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
},
)

# 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
},
)

def create_room(
self,
direct=False,
Expand Down