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

Commit 722e1c0

Browse files
"Freeze" a room when the last admin of that room leaves (#59)
If the last admin of a room departs, and thus the room no longer has any admins within it, we "freeze" the room. Freezing a room means that the power level required to do anything in the room (sending messages, inviting others etc) will require power level 100. At the moment, an admin can come back and unfreeze the room manually. The plan is to eventually make unfreezing of the room automatic on admin rejoin, though that will be in a separate PR. This *could* work in mainline, however if the admin who leaves is on a homeserver without this functionality, then the room isn't frozen. I imagine this would probably be pretty confusing to people. Part of this feature was allowing Synapse modules to send events, which has been implemented in mainline at matrix-org/synapse#8479, and cherry-picked to the `dinsic` fork in 62c7b10. The actual freezing logic has been implemented here in the RoomAccessRules module.
1 parent 62c7b10 commit 722e1c0

File tree

3 files changed

+302
-4
lines changed

3 files changed

+302
-4
lines changed

changelog.d/59.feature

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Freeze a room when the last administrator in the room leaves.

synapse/third_party_rules/access_rules.py

Lines changed: 171 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
# See the License for the specific language governing permissions and
1414
# limitations under the License.
1515
import email.utils
16+
import logging
1617
from typing import Dict, List, Optional, Tuple
1718

1819
from twisted.internet import defer
@@ -22,7 +23,9 @@
2223
from synapse.config._base import ConfigError
2324
from synapse.events import EventBase
2425
from synapse.module_api import ModuleApi
25-
from synapse.types import Requester, StateMap, get_domain_from_id
26+
from synapse.types import Requester, StateMap, UserID, get_domain_from_id
27+
28+
logger = logging.getLogger(__name__)
2629

2730
ACCESS_RULES_TYPE = "im.vector.room.access_rules"
2831

@@ -323,7 +326,7 @@ async def check_event_allowed(
323326
)
324327

325328
if event.type == EventTypes.Member or event.type == EventTypes.ThirdPartyInvite:
326-
return self._on_membership_or_invite(event, rule, state_events)
329+
return await self._on_membership_or_invite(event, rule, state_events)
327330

328331
if event.type == EventTypes.JoinRules:
329332
return self._on_join_rule_change(event, rule)
@@ -420,7 +423,7 @@ async def _on_rules_change(
420423
prev_rule == AccessRules.RESTRICTED and new_rule == AccessRules.UNRESTRICTED
421424
)
422425

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

452+
if event.type == "m.room.member":
453+
# If this is an admin leaving, and they are the last admin in the room,
454+
# raise the power levels of the room so that the room is 'frozen'.
455+
#
456+
# We have to freeze the room by puppeting an admin user, which we can
457+
# only do for local users
458+
if (
459+
self._is_local_user(event.sender)
460+
and event.membership == Membership.LEAVE
461+
):
462+
await self._freeze_room_if_last_admin_is_leaving(event, state_events)
463+
449464
return ret
450465

466+
async def _freeze_room_if_last_admin_is_leaving(
467+
self, event: EventBase, state_events: StateMap[EventBase]
468+
):
469+
power_level_state_event = state_events.get(
470+
(EventTypes.PowerLevels, "")
471+
) # type: EventBase
472+
if not power_level_state_event:
473+
return
474+
power_level_content = power_level_state_event.content
475+
476+
# Do some validation checks on the power level state event
477+
if (
478+
not isinstance(power_level_content, dict)
479+
or "users" not in power_level_content
480+
or not isinstance(power_level_content["users"], dict)
481+
):
482+
# We can't use this power level event to determine whether the room should be
483+
# frozen. Bail out.
484+
return
485+
486+
user_id = event.get("sender")
487+
if not user_id:
488+
return
489+
490+
# Get every admin user defined in the room's state
491+
admin_users = {
492+
user
493+
for user, power_level in power_level_content["users"].items()
494+
if power_level >= 100
495+
}
496+
497+
if user_id not in admin_users:
498+
# This user is not an admin, ignore them
499+
return
500+
501+
if any(
502+
event_type == EventTypes.Member
503+
and event.membership in [Membership.JOIN, Membership.INVITE]
504+
and state_key in admin_users
505+
and state_key != user_id
506+
for (event_type, state_key), event in state_events.items()
507+
):
508+
# There's another admin user in, or invited to, the room
509+
return
510+
511+
# Freeze the room by raising the required power level to send events to 100
512+
logger.info("Freezing room '%s'", event.room_id)
513+
514+
# Modify the existing power levels to raise all required types to 100
515+
#
516+
# This changes a power level state event's content from something like:
517+
# {
518+
# "redact": 50,
519+
# "state_default": 50,
520+
# "ban": 50,
521+
# "notifications": {
522+
# "room": 50
523+
# },
524+
# "events": {
525+
# "m.room.avatar": 50,
526+
# "m.room.encryption": 50,
527+
# "m.room.canonical_alias": 50,
528+
# "m.room.name": 50,
529+
# "im.vector.modular.widgets": 50,
530+
# "m.room.topic": 50,
531+
# "m.room.tombstone": 50,
532+
# "m.room.history_visibility": 100,
533+
# "m.room.power_levels": 100
534+
# },
535+
# "users_default": 0,
536+
# "events_default": 0,
537+
# "users": {
538+
# "@admin:example.com": 100,
539+
# },
540+
# "kick": 50,
541+
# "invite": 0
542+
# }
543+
#
544+
# to
545+
#
546+
# {
547+
# "redact": 100,
548+
# "state_default": 100,
549+
# "ban": 100,
550+
# "notifications": {
551+
# "room": 50
552+
# },
553+
# "events": {}
554+
# "users_default": 0,
555+
# "events_default": 100,
556+
# "users": {
557+
# "@admin:example.com": 100,
558+
# },
559+
# "kick": 100,
560+
# "invite": 100
561+
# }
562+
new_content = {}
563+
for key, value in power_level_content.items():
564+
# Do not change "users_default", as that key specifies the default power
565+
# level of new users
566+
if isinstance(value, int) and key != "users_default":
567+
value = 100
568+
new_content[key] = value
569+
570+
# Set some values in case they are missing from the original
571+
# power levels event content
572+
new_content.update(
573+
{
574+
# Clear out any special-cased event keys
575+
"events": {},
576+
# Ensure state_default and events_default keys exist and are 100.
577+
# Otherwise a lower PL user could potentially send state events that
578+
# aren't explicitly mentioned elsewhere in the power level dict
579+
"state_default": 100,
580+
"events_default": 100,
581+
# Membership events default to 50 if they aren't present. Set them
582+
# to 100 here, as they would be set to 100 if they were present anyways
583+
"ban": 100,
584+
"kick": 100,
585+
"invite": 100,
586+
"redact": 100,
587+
}
588+
)
589+
590+
await self.module_api.create_and_send_event_into_room(
591+
{
592+
"room_id": event.room_id,
593+
"sender": user_id,
594+
"type": EventTypes.PowerLevels,
595+
"content": new_content,
596+
"state_key": "",
597+
}
598+
)
599+
451600
def _on_membership_or_invite_restricted(self, event: EventBase) -> bool:
452601
"""Implements the checks and behaviour specified for the "restricted" rule.
453602
@@ -753,6 +902,25 @@ def _is_invite_from_threepid(invite: EventBase, threepid_invite_token: str) -> b
753902

754903
return token == threepid_invite_token
755904

905+
def _is_local_user(self, user_id: str) -> bool:
906+
"""Checks whether a given user ID belongs to this homeserver, or a remote
907+
908+
Args:
909+
user_id: A user ID to check.
910+
911+
Returns:
912+
True if the user belongs to this homeserver, False otherwise.
913+
"""
914+
user = UserID.from_string(user_id)
915+
916+
# Extract the localpart and ask the module API for a user ID from the localpart
917+
# The module API will append the local homeserver's server_name
918+
local_user_id = self.module_api.get_qualified_user_id(user.localpart)
919+
920+
# If the user ID we get based on the localpart is the same as the original user ID,
921+
# then they were a local user
922+
return user_id == local_user_id
923+
756924
def _user_is_invited_to_room(
757925
self, user_id: str, state_events: StateMap[EventBase]
758926
) -> bool:

tests/rest/client/test_room_access_rules.py

Lines changed: 130 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
import json
1616
import random
1717
import string
18+
from typing import Optional
1819

1920
from mock import Mock
2021

@@ -28,7 +29,7 @@
2829
AccessRules,
2930
RoomAccessRules,
3031
)
31-
from synapse.types import create_requester
32+
from synapse.types import JsonDict, create_requester
3233

3334
from tests import unittest
3435

@@ -840,6 +841,134 @@ def test_check_event_allowed(self):
840841
)
841842
self.assertTrue(can_join)
842843

844+
def test_freezing_a_room(self):
845+
"""Tests that the power levels in a room change to prevent new events from
846+
non-admin users when the last admin of a room leaves.
847+
"""
848+
849+
def freeze_room_with_id_and_power_levels(
850+
room_id: str, custom_power_levels_content: Optional[JsonDict] = None,
851+
):
852+
# Invite a user to the room, they join with PL 0
853+
self.helper.invite(
854+
room=room_id, src=self.user_id, targ=self.invitee_id, tok=self.tok,
855+
)
856+
857+
# Invitee joins the room
858+
self.helper.join(
859+
room=room_id, user=self.invitee_id, tok=self.invitee_tok,
860+
)
861+
862+
if not custom_power_levels_content:
863+
# Retrieve the room's current power levels event content
864+
power_levels = self.helper.get_state(
865+
room_id=room_id, event_type="m.room.power_levels", tok=self.tok,
866+
)
867+
else:
868+
power_levels = custom_power_levels_content
869+
870+
# Override the room's power levels with the given power levels content
871+
self.helper.send_state(
872+
room_id=room_id,
873+
event_type="m.room.power_levels",
874+
body=custom_power_levels_content,
875+
tok=self.tok,
876+
)
877+
878+
# Ensure that the invitee leaving the room does not change the power levels
879+
self.helper.leave(
880+
room=room_id, user=self.invitee_id, tok=self.invitee_tok,
881+
)
882+
883+
# Retrieve the new power levels of the room
884+
new_power_levels = self.helper.get_state(
885+
room_id=room_id, event_type="m.room.power_levels", tok=self.tok,
886+
)
887+
888+
# Ensure they have not changed
889+
self.assertDictEqual(power_levels, new_power_levels)
890+
891+
# Invite the user back again
892+
self.helper.invite(
893+
room=room_id, src=self.user_id, targ=self.invitee_id, tok=self.tok,
894+
)
895+
896+
# Invitee joins the room
897+
self.helper.join(
898+
room=room_id, user=self.invitee_id, tok=self.invitee_tok,
899+
)
900+
901+
# Now the admin leaves the room
902+
self.helper.leave(
903+
room=room_id, user=self.user_id, tok=self.tok,
904+
)
905+
906+
# Check the power levels again
907+
new_power_levels = self.helper.get_state(
908+
room_id=room_id, event_type="m.room.power_levels", tok=self.invitee_tok,
909+
)
910+
911+
# Ensure that the new power levels prevent anyone but admins from sending
912+
# certain events
913+
self.assertEquals(new_power_levels["state_default"], 100)
914+
self.assertEquals(new_power_levels["events_default"], 100)
915+
self.assertEquals(new_power_levels["kick"], 100)
916+
self.assertEquals(new_power_levels["invite"], 100)
917+
self.assertEquals(new_power_levels["ban"], 100)
918+
self.assertEquals(new_power_levels["redact"], 100)
919+
self.assertDictEqual(new_power_levels["events"], {})
920+
self.assertDictEqual(new_power_levels["users"], {self.user_id: 100})
921+
922+
# Ensure new users entering the room aren't going to immediately become admins
923+
self.assertEquals(new_power_levels["users_default"], 0)
924+
925+
# Test that freezing a room with the default power level state event content works
926+
room1 = self.create_room()
927+
freeze_room_with_id_and_power_levels(room1)
928+
929+
# Test that freezing a room with a power level state event that is missing
930+
# `state_default` and `event_default` keys behaves as expected
931+
room2 = self.create_room()
932+
freeze_room_with_id_and_power_levels(
933+
room2,
934+
{
935+
"ban": 50,
936+
"events": {
937+
"m.room.avatar": 50,
938+
"m.room.canonical_alias": 50,
939+
"m.room.history_visibility": 100,
940+
"m.room.name": 50,
941+
"m.room.power_levels": 100,
942+
},
943+
"invite": 0,
944+
"kick": 50,
945+
"redact": 50,
946+
"users": {self.user_id: 100},
947+
"users_default": 0,
948+
# Explicitly remove `state_default` and `event_default` keys
949+
},
950+
)
951+
952+
# Test that freezing a room with a power level state event that is *additionally*
953+
# missing `ban`, `invite`, `kick` and `redact` keys behaves as expected
954+
room3 = self.create_room()
955+
freeze_room_with_id_and_power_levels(
956+
room3,
957+
{
958+
"events": {
959+
"m.room.avatar": 50,
960+
"m.room.canonical_alias": 50,
961+
"m.room.history_visibility": 100,
962+
"m.room.name": 50,
963+
"m.room.power_levels": 100,
964+
},
965+
"users": {self.user_id: 100},
966+
"users_default": 0,
967+
# Explicitly remove `state_default` and `event_default` keys
968+
# Explicitly remove `ban`, `invite`, `kick` and `redact` keys
969+
},
970+
)
971+
843972
def create_room(
844973
self,
845974
direct=False,

0 commit comments

Comments
 (0)