Sliding Sync: Update filters to be robust against remote invite rooms (#17450)

Update `filters.is_encrypted` and `filters.types`/`filters.not_types` to
be robust when dealing with remote invite rooms in Sliding Sync.

Part of
[MSC3575](https://github.com/matrix-org/matrix-spec-proposals/pull/3575):
Sliding Sync

Follow-up to https://github.com/element-hq/synapse/pull/17434

We now take into account current state, fallback to stripped state
for invite/knock rooms, then historical state. If we can't determine
the info needed to filter a room (either from state or stripped state),
it is filtered out.
This commit is contained in:
Eric Eastwood 2024-07-30 13:20:29 -05:00 committed by GitHub
parent b221f0b84b
commit 46de0ee16b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 1605 additions and 117 deletions

1
changelog.d/17450.bugfix Normal file
View File

@ -0,0 +1 @@
Update experimental [MSC3575](https://github.com/matrix-org/matrix-spec-proposals/pull/3575) Sliding Sync `/sync` endpoint to handle invite/knock rooms when filtering.

View File

@ -225,6 +225,11 @@ class EventContentFields:
# This is deprecated in MSC2175. # This is deprecated in MSC2175.
ROOM_CREATOR: Final = "creator" ROOM_CREATOR: Final = "creator"
# The version of the room for `m.room.create` events.
ROOM_VERSION: Final = "room_version"
ROOM_NAME: Final = "name"
# Used in m.room.guest_access events. # Used in m.room.guest_access events.
GUEST_ACCESS: Final = "guest_access" GUEST_ACCESS: Final = "guest_access"
@ -237,6 +242,9 @@ class EventContentFields:
# an unspecced field added to to-device messages to identify them uniquely-ish # an unspecced field added to to-device messages to identify them uniquely-ish
TO_DEVICE_MSGID: Final = "org.matrix.msgid" TO_DEVICE_MSGID: Final = "org.matrix.msgid"
# `m.room.encryption`` algorithm field
ENCRYPTION_ALGORITHM: Final = "algorithm"
class EventUnsignedContentFields: class EventUnsignedContentFields:
"""Fields found inside the 'unsigned' data on events""" """Fields found inside the 'unsigned' data on events"""

View File

@ -554,3 +554,22 @@ def relation_from_event(event: EventBase) -> Optional[_EventRelation]:
aggregation_key = None aggregation_key = None
return _EventRelation(parent_id, rel_type, aggregation_key) return _EventRelation(parent_id, rel_type, aggregation_key)
@attr.s(slots=True, frozen=True, auto_attribs=True)
class StrippedStateEvent:
"""
A stripped down state event. Usually used for remote invite/knocks so the user can
make an informed decision on whether they want to join.
Attributes:
type: Event `type`
state_key: Event `state_key`
sender: Event `sender`
content: Event `content`
"""
type: str
state_key: str
sender: str
content: Dict[str, Any]

View File

@ -49,7 +49,7 @@ from synapse.api.errors import Codes, SynapseError
from synapse.api.room_versions import RoomVersion from synapse.api.room_versions import RoomVersion
from synapse.types import JsonDict, Requester from synapse.types import JsonDict, Requester
from . import EventBase, make_event_from_dict from . import EventBase, StrippedStateEvent, make_event_from_dict
if TYPE_CHECKING: if TYPE_CHECKING:
from synapse.handlers.relations import BundledAggregations from synapse.handlers.relations import BundledAggregations
@ -854,3 +854,30 @@ def strip_event(event: EventBase) -> JsonDict:
"content": event.content, "content": event.content,
"sender": event.sender, "sender": event.sender,
} }
def parse_stripped_state_event(raw_stripped_event: Any) -> Optional[StrippedStateEvent]:
"""
Given a raw value from an event's `unsigned` field, attempt to parse it into a
`StrippedStateEvent`.
"""
if isinstance(raw_stripped_event, dict):
# All of these fields are required
type = raw_stripped_event.get("type")
state_key = raw_stripped_event.get("state_key")
sender = raw_stripped_event.get("sender")
content = raw_stripped_event.get("content")
if (
isinstance(type, str)
and isinstance(state_key, str)
and isinstance(sender, str)
and isinstance(content, dict)
):
return StrippedStateEvent(
type=type,
state_key=state_key,
sender=sender,
content=content,
)
return None

View File

@ -17,6 +17,7 @@
# [This file includes modifications made by New Vector Limited] # [This file includes modifications made by New Vector Limited]
# #
# #
import enum
import logging import logging
from enum import Enum from enum import Enum
from itertools import chain from itertools import chain
@ -26,23 +27,35 @@ from typing import (
Dict, Dict,
Final, Final,
List, List,
Literal,
Mapping, Mapping,
Optional, Optional,
Sequence, Sequence,
Set, Set,
Tuple, Tuple,
Union,
) )
import attr import attr
from immutabledict import immutabledict from immutabledict import immutabledict
from typing_extensions import assert_never from typing_extensions import assert_never
from synapse.api.constants import AccountDataTypes, Direction, EventTypes, Membership from synapse.api.constants import (
from synapse.events import EventBase AccountDataTypes,
from synapse.events.utils import strip_event Direction,
EventContentFields,
EventTypes,
Membership,
)
from synapse.events import EventBase, StrippedStateEvent
from synapse.events.utils import parse_stripped_state_event, strip_event
from synapse.handlers.relations import BundledAggregations from synapse.handlers.relations import BundledAggregations
from synapse.logging.opentracing import log_kv, start_active_span, tag_args, trace from synapse.logging.opentracing import log_kv, start_active_span, tag_args, trace
from synapse.storage.databases.main.roommember import extract_heroes_from_room_summary from synapse.storage.databases.main.roommember import extract_heroes_from_room_summary
from synapse.storage.databases.main.state import (
ROOM_UNKNOWN_SENTINEL,
Sentinel as StateSentinel,
)
from synapse.storage.databases.main.stream import CurrentStateDeltaMembership from synapse.storage.databases.main.stream import CurrentStateDeltaMembership
from synapse.storage.roommember import MemberSummary from synapse.storage.roommember import MemberSummary
from synapse.types import ( from synapse.types import (
@ -50,6 +63,7 @@ from synapse.types import (
JsonDict, JsonDict,
JsonMapping, JsonMapping,
MultiWriterStreamToken, MultiWriterStreamToken,
MutableStateMap,
PersistedEventPosition, PersistedEventPosition,
Requester, Requester,
RoomStreamToken, RoomStreamToken,
@ -71,6 +85,12 @@ if TYPE_CHECKING:
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class Sentinel(enum.Enum):
# defining a sentinel in this way allows mypy to correctly handle the
# type of a dictionary lookup and subsequent type narrowing.
UNSET_SENTINEL = object()
# The event types that clients should consider as new activity. # The event types that clients should consider as new activity.
DEFAULT_BUMP_EVENT_TYPES = { DEFAULT_BUMP_EVENT_TYPES = {
EventTypes.Create, EventTypes.Create,
@ -1172,6 +1192,265 @@ class SlidingSyncHandler:
# return None # return None
async def _bulk_get_stripped_state_for_rooms_from_sync_room_map(
self,
room_ids: StrCollection,
sync_room_map: Dict[str, _RoomMembershipForUser],
) -> Dict[str, Optional[StateMap[StrippedStateEvent]]]:
"""
Fetch stripped state for a list of room IDs. Stripped state is only
applicable to invite/knock rooms. Other rooms will have `None` as their
stripped state.
For invite rooms, we pull from `unsigned.invite_room_state`.
For knock rooms, we pull from `unsigned.knock_room_state`.
Args:
room_ids: Room IDs to fetch stripped state for
sync_room_map: Dictionary of room IDs to sort along with membership
information in the room at the time of `to_token`.
Returns:
Mapping from room_id to mapping of (type, state_key) to stripped state
event.
"""
room_id_to_stripped_state_map: Dict[
str, Optional[StateMap[StrippedStateEvent]]
] = {}
# Fetch what we haven't before
room_ids_to_fetch = [
room_id
for room_id in room_ids
if room_id not in room_id_to_stripped_state_map
]
# Gather a list of event IDs we can grab stripped state from
invite_or_knock_event_ids: List[str] = []
for room_id in room_ids_to_fetch:
if sync_room_map[room_id].membership in (
Membership.INVITE,
Membership.KNOCK,
):
event_id = sync_room_map[room_id].event_id
# If this is an invite/knock then there should be an event_id
assert event_id is not None
invite_or_knock_event_ids.append(event_id)
else:
room_id_to_stripped_state_map[room_id] = None
invite_or_knock_events = await self.store.get_events(invite_or_knock_event_ids)
for invite_or_knock_event in invite_or_knock_events.values():
room_id = invite_or_knock_event.room_id
membership = invite_or_knock_event.membership
raw_stripped_state_events = None
if membership == Membership.INVITE:
invite_room_state = invite_or_knock_event.unsigned.get(
"invite_room_state"
)
raw_stripped_state_events = invite_room_state
elif membership == Membership.KNOCK:
knock_room_state = invite_or_knock_event.unsigned.get(
"knock_room_state"
)
raw_stripped_state_events = knock_room_state
else:
raise AssertionError(
f"Unexpected membership {membership} (this is a problem with Synapse itself)"
)
stripped_state_map: Optional[MutableStateMap[StrippedStateEvent]] = None
# Scrutinize unsigned things. `raw_stripped_state_events` should be a list
# of stripped events
if raw_stripped_state_events is not None:
stripped_state_map = {}
if isinstance(raw_stripped_state_events, list):
for raw_stripped_event in raw_stripped_state_events:
stripped_state_event = parse_stripped_state_event(
raw_stripped_event
)
if stripped_state_event is not None:
stripped_state_map[
(
stripped_state_event.type,
stripped_state_event.state_key,
)
] = stripped_state_event
room_id_to_stripped_state_map[room_id] = stripped_state_map
return room_id_to_stripped_state_map
async def _bulk_get_partial_current_state_content_for_rooms(
self,
content_type: Literal[
# `content.type` from `EventTypes.Create``
"room_type",
# `content.algorithm` from `EventTypes.RoomEncryption`
"room_encryption",
],
room_ids: Set[str],
sync_room_map: Dict[str, _RoomMembershipForUser],
to_token: StreamToken,
room_id_to_stripped_state_map: Dict[
str, Optional[StateMap[StrippedStateEvent]]
],
) -> Mapping[str, Union[Optional[str], StateSentinel]]:
"""
Get the given state event content for a list of rooms. First we check the
current state of the room, then fallback to stripped state if available, then
historical state.
Args:
content_type: Which content to grab
room_ids: Room IDs to fetch the given content field for.
sync_room_map: Dictionary of room IDs to sort along with membership
information in the room at the time of `to_token`.
to_token: We filter based on the state of the room at this token
room_id_to_stripped_state_map: This does not need to be filled in before
calling this function. Mapping from room_id to mapping of (type, state_key)
to stripped state event. Modified in place when we fetch new rooms so we can
save work next time this function is called.
Returns:
A mapping from room ID to the state event content if the room has
the given state event (event_type, ""), otherwise `None`. Rooms unknown to
this server will return `ROOM_UNKNOWN_SENTINEL`.
"""
room_id_to_content: Dict[str, Union[Optional[str], StateSentinel]] = {}
# As a bulk shortcut, use the current state if the server is particpating in the
# room (meaning we have current state). Ideally, for leave/ban rooms, we would
# want the state at the time of the membership instead of current state to not
# leak anything but we consider the create/encryption stripped state events to
# not be a secret given they are often set at the start of the room and they are
# normally handed out on invite/knock.
#
# Be mindful to only use this for non-sensitive details. For example, even
# though the room name/avatar/topic are also stripped state, they seem a lot
# more senstive to leak the current state value of.
#
# Since this function is cached, we need to make a mutable copy via
# `dict(...)`.
event_type = ""
event_content_field = ""
if content_type == "room_type":
event_type = EventTypes.Create
event_content_field = EventContentFields.ROOM_TYPE
room_id_to_content = dict(await self.store.bulk_get_room_type(room_ids))
elif content_type == "room_encryption":
event_type = EventTypes.RoomEncryption
event_content_field = EventContentFields.ENCRYPTION_ALGORITHM
room_id_to_content = dict(
await self.store.bulk_get_room_encryption(room_ids)
)
else:
assert_never(content_type)
room_ids_with_results = [
room_id
for room_id, content_field in room_id_to_content.items()
if content_field is not ROOM_UNKNOWN_SENTINEL
]
# We might not have current room state for remote invite/knocks if we are
# the first person on our server to see the room. The best we can do is look
# in the optional stripped state from the invite/knock event.
room_ids_without_results = room_ids.difference(
chain(
room_ids_with_results,
[
room_id
for room_id, stripped_state_map in room_id_to_stripped_state_map.items()
if stripped_state_map is not None
],
)
)
room_id_to_stripped_state_map.update(
await self._bulk_get_stripped_state_for_rooms_from_sync_room_map(
room_ids_without_results, sync_room_map
)
)
# Update our `room_id_to_content` map based on the stripped state
# (applies to invite/knock rooms)
rooms_ids_without_stripped_state: Set[str] = set()
for room_id in room_ids_without_results:
stripped_state_map = room_id_to_stripped_state_map.get(
room_id, Sentinel.UNSET_SENTINEL
)
assert stripped_state_map is not Sentinel.UNSET_SENTINEL, (
f"Stripped state left unset for room {room_id}. "
+ "Make sure you're calling `_bulk_get_stripped_state_for_rooms_from_sync_room_map(...)` "
+ "with that room_id. (this is a problem with Synapse itself)"
)
# If there is some stripped state, we assume the remote server passed *all*
# of the potential stripped state events for the room.
if stripped_state_map is not None:
create_stripped_event = stripped_state_map.get((EventTypes.Create, ""))
stripped_event = stripped_state_map.get((event_type, ""))
# Sanity check that we at-least have the create event
if create_stripped_event is not None:
if stripped_event is not None:
room_id_to_content[room_id] = stripped_event.content.get(
event_content_field
)
else:
# Didn't see the state event we're looking for in the stripped
# state so we can assume relevant content field is `None`.
room_id_to_content[room_id] = None
else:
rooms_ids_without_stripped_state.add(room_id)
# Last resort, we might not have current room state for rooms that the
# server has left (no one local is in the room) but we can look at the
# historical state.
#
# Update our `room_id_to_content` map based on the state at the time of
# the membership event.
for room_id in rooms_ids_without_stripped_state:
# TODO: It would be nice to look this up in a bulk way (N+1 queries)
#
# TODO: `get_state_at(...)` doesn't take into account the "current state".
room_state = await self.storage_controllers.state.get_state_at(
room_id=room_id,
stream_position=to_token.copy_and_replace(
StreamKeyType.ROOM,
sync_room_map[room_id].event_pos.to_room_stream_token(),
),
state_filter=StateFilter.from_types(
[
(EventTypes.Create, ""),
(event_type, ""),
]
),
# Partially-stated rooms should have all state events except for
# remote membership events so we don't need to wait at all because
# we only want the create event and some non-member event.
await_full_state=False,
)
# We can use the create event as a canary to tell whether the server has
# seen the room before
create_event = room_state.get((EventTypes.Create, ""))
state_event = room_state.get((event_type, ""))
if create_event is None:
# Skip for unknown rooms
continue
if state_event is not None:
room_id_to_content[room_id] = state_event.content.get(
event_content_field
)
else:
# Didn't see the state event we're looking for in the stripped
# state so we can assume relevant content field is `None`.
room_id_to_content[room_id] = None
return room_id_to_content
@trace @trace
async def filter_rooms( async def filter_rooms(
self, self,
@ -1194,6 +1473,10 @@ class SlidingSyncHandler:
A filtered dictionary of room IDs along with membership information in the A filtered dictionary of room IDs along with membership information in the
room at the time of `to_token`. room at the time of `to_token`.
""" """
room_id_to_stripped_state_map: Dict[
str, Optional[StateMap[StrippedStateEvent]]
] = {}
filtered_room_id_set = set(sync_room_map.keys()) filtered_room_id_set = set(sync_room_map.keys())
# Filter for Direct-Message (DM) rooms # Filter for Direct-Message (DM) rooms
@ -1213,31 +1496,34 @@ class SlidingSyncHandler:
if not sync_room_map[room_id].is_dm if not sync_room_map[room_id].is_dm
} }
if filters.spaces: if filters.spaces is not None:
raise NotImplementedError() raise NotImplementedError()
# Filter for encrypted rooms # Filter for encrypted rooms
if filters.is_encrypted is not None: if filters.is_encrypted is not None:
room_id_to_encryption = (
await self._bulk_get_partial_current_state_content_for_rooms(
content_type="room_encryption",
room_ids=filtered_room_id_set,
to_token=to_token,
sync_room_map=sync_room_map,
room_id_to_stripped_state_map=room_id_to_stripped_state_map,
)
)
# Make a copy so we don't run into an error: `Set changed size during # Make a copy so we don't run into an error: `Set changed size during
# iteration`, when we filter out and remove items # iteration`, when we filter out and remove items
for room_id in filtered_room_id_set.copy(): for room_id in filtered_room_id_set.copy():
state_at_to_token = await self.storage_controllers.state.get_state_at( encryption = room_id_to_encryption.get(room_id, ROOM_UNKNOWN_SENTINEL)
room_id,
to_token, # Just remove rooms if we can't determine their encryption status
state_filter=StateFilter.from_types( if encryption is ROOM_UNKNOWN_SENTINEL:
[(EventTypes.RoomEncryption, "")] filtered_room_id_set.remove(room_id)
), continue
# Partially-stated rooms should have all state events except for the
# membership events so we don't need to wait because we only care
# about retrieving the `EventTypes.RoomEncryption` state event here.
# Plus we don't want to block the whole sync waiting for this one
# room.
await_full_state=False,
)
is_encrypted = state_at_to_token.get((EventTypes.RoomEncryption, ""))
# If we're looking for encrypted rooms, filter out rooms that are not # If we're looking for encrypted rooms, filter out rooms that are not
# encrypted and vice versa # encrypted and vice versa
is_encrypted = encryption is not None
if (filters.is_encrypted and not is_encrypted) or ( if (filters.is_encrypted and not is_encrypted) or (
not filters.is_encrypted and is_encrypted not filters.is_encrypted and is_encrypted
): ):
@ -1263,15 +1549,26 @@ class SlidingSyncHandler:
# provided in the list. `None` is a valid type for rooms which do not have a # provided in the list. `None` is a valid type for rooms which do not have a
# room type. # room type.
if filters.room_types is not None or filters.not_room_types is not None: if filters.room_types is not None or filters.not_room_types is not None:
room_to_type = await self.store.bulk_get_room_type( room_id_to_type = (
{ await self._bulk_get_partial_current_state_content_for_rooms(
room_id content_type="room_type",
for room_id in filtered_room_id_set room_ids=filtered_room_id_set,
# We only know the room types for joined rooms to_token=to_token,
if sync_room_map[room_id].membership == Membership.JOIN sync_room_map=sync_room_map,
} room_id_to_stripped_state_map=room_id_to_stripped_state_map,
) )
for room_id, room_type in room_to_type.items(): )
# Make a copy so we don't run into an error: `Set changed size during
# iteration`, when we filter out and remove items
for room_id in filtered_room_id_set.copy():
room_type = room_id_to_type.get(room_id, ROOM_UNKNOWN_SENTINEL)
# Just remove rooms if we can't determine their type
if room_type is ROOM_UNKNOWN_SENTINEL:
filtered_room_id_set.remove(room_id)
continue
if ( if (
filters.room_types is not None filters.room_types is not None
and room_type not in filters.room_types and room_type not in filters.room_types
@ -1284,13 +1581,24 @@ class SlidingSyncHandler:
): ):
filtered_room_id_set.remove(room_id) filtered_room_id_set.remove(room_id)
if filters.room_name_like: if filters.room_name_like is not None:
# TODO: The room name is a bit more sensitive to leak than the
# create/encryption event. Maybe we should consider a better way to fetch
# historical state before implementing this.
#
# room_id_to_create_content = await self._bulk_get_partial_current_state_content_for_rooms(
# content_type="room_name",
# room_ids=filtered_room_id_set,
# to_token=to_token,
# sync_room_map=sync_room_map,
# room_id_to_stripped_state_map=room_id_to_stripped_state_map,
# )
raise NotImplementedError() raise NotImplementedError()
if filters.tags: if filters.tags is not None:
raise NotImplementedError() raise NotImplementedError()
if filters.not_tags: if filters.not_tags is not None:
raise NotImplementedError() raise NotImplementedError()
# Assemble a new sync room map but only with the `filtered_room_id_set` # Assemble a new sync room map but only with the `filtered_room_id_set`
@ -1371,14 +1679,17 @@ class SlidingSyncHandler:
in the room at the time of `to_token`. in the room at the time of `to_token`.
to_token: The point in the stream to sync up to. to_token: The point in the stream to sync up to.
""" """
room_state_ids: StateMap[str] state_ids: StateMap[str]
# People shouldn't see past their leave/ban event # People shouldn't see past their leave/ban event
if room_membership_for_user_at_to_token.membership in ( if room_membership_for_user_at_to_token.membership in (
Membership.LEAVE, Membership.LEAVE,
Membership.BAN, Membership.BAN,
): ):
# TODO: `get_state_ids_at(...)` doesn't take into account the "current state" # TODO: `get_state_ids_at(...)` doesn't take into account the "current
room_state_ids = await self.storage_controllers.state.get_state_ids_at( # state". Maybe we need to use
# `get_forward_extremities_for_room_at_stream_ordering(...)` to "Fetch the
# current state at the time."
state_ids = await self.storage_controllers.state.get_state_ids_at(
room_id, room_id,
stream_position=to_token.copy_and_replace( stream_position=to_token.copy_and_replace(
StreamKeyType.ROOM, StreamKeyType.ROOM,
@ -1397,7 +1708,7 @@ class SlidingSyncHandler:
) )
# Otherwise, we can get the latest current state in the room # Otherwise, we can get the latest current state in the room
else: else:
room_state_ids = await self.storage_controllers.state.get_current_state_ids( state_ids = await self.storage_controllers.state.get_current_state_ids(
room_id, room_id,
state_filter, state_filter,
# Partially-stated rooms should have all state events except for # Partially-stated rooms should have all state events except for
@ -1412,7 +1723,7 @@ class SlidingSyncHandler:
) )
# TODO: Query `current_state_delta_stream` and reverse/rewind back to the `to_token` # TODO: Query `current_state_delta_stream` and reverse/rewind back to the `to_token`
return room_state_ids return state_ids
async def get_current_state_at( async def get_current_state_at(
self, self,
@ -1432,17 +1743,17 @@ class SlidingSyncHandler:
in the room at the time of `to_token`. in the room at the time of `to_token`.
to_token: The point in the stream to sync up to. to_token: The point in the stream to sync up to.
""" """
room_state_ids = await self.get_current_state_ids_at( state_ids = await self.get_current_state_ids_at(
room_id=room_id, room_id=room_id,
room_membership_for_user_at_to_token=room_membership_for_user_at_to_token, room_membership_for_user_at_to_token=room_membership_for_user_at_to_token,
state_filter=state_filter, state_filter=state_filter,
to_token=to_token, to_token=to_token,
) )
event_map = await self.store.get_events(list(room_state_ids.values())) event_map = await self.store.get_events(list(state_ids.values()))
state_map = {} state_map = {}
for key, event_id in room_state_ids.items(): for key, event_id in state_ids.items():
event = event_map.get(event_id) event = event_map.get(event_id)
if event: if event:
state_map[key] = event state_map[key] = event

View File

@ -293,7 +293,9 @@ class StatsHandler:
"history_visibility" "history_visibility"
) )
elif delta.event_type == EventTypes.RoomEncryption: elif delta.event_type == EventTypes.RoomEncryption:
room_state["encryption"] = event_content.get("algorithm") room_state["encryption"] = event_content.get(
EventContentFields.ENCRYPTION_ALGORITHM
)
elif delta.event_type == EventTypes.Name: elif delta.event_type == EventTypes.Name:
room_state["name"] = event_content.get("name") room_state["name"] = event_content.get("name")
elif delta.event_type == EventTypes.Topic: elif delta.event_type == EventTypes.Topic:

View File

@ -127,6 +127,8 @@ class SQLBaseStore(metaclass=ABCMeta):
# Purge other caches based on room state. # Purge other caches based on room state.
self._attempt_to_invalidate_cache("get_room_summary", (room_id,)) self._attempt_to_invalidate_cache("get_room_summary", (room_id,))
self._attempt_to_invalidate_cache("get_partial_current_state_ids", (room_id,)) self._attempt_to_invalidate_cache("get_partial_current_state_ids", (room_id,))
self._attempt_to_invalidate_cache("get_room_type", (room_id,))
self._attempt_to_invalidate_cache("get_room_encryption", (room_id,))
def _invalidate_state_caches_all(self, room_id: str) -> None: def _invalidate_state_caches_all(self, room_id: str) -> None:
"""Invalidates caches that are based on the current state, but does """Invalidates caches that are based on the current state, but does
@ -153,6 +155,8 @@ class SQLBaseStore(metaclass=ABCMeta):
"_get_rooms_for_local_user_where_membership_is_inner", None "_get_rooms_for_local_user_where_membership_is_inner", None
) )
self._attempt_to_invalidate_cache("get_room_summary", (room_id,)) self._attempt_to_invalidate_cache("get_room_summary", (room_id,))
self._attempt_to_invalidate_cache("get_room_type", (room_id,))
self._attempt_to_invalidate_cache("get_room_encryption", (room_id,))
def _attempt_to_invalidate_cache( def _attempt_to_invalidate_cache(
self, cache_name: str, key: Optional[Collection[Any]] self, cache_name: str, key: Optional[Collection[Any]]

View File

@ -268,13 +268,23 @@ class CacheInvalidationWorkerStore(SQLBaseStore):
self._curr_state_delta_stream_cache.entity_has_changed(data.room_id, token) # type: ignore[attr-defined] self._curr_state_delta_stream_cache.entity_has_changed(data.room_id, token) # type: ignore[attr-defined]
if data.type == EventTypes.Member: if data.type == EventTypes.Member:
self.get_rooms_for_user.invalidate((data.state_key,)) # type: ignore[attr-defined] self._attempt_to_invalidate_cache(
"get_rooms_for_user", (data.state_key,)
)
elif data.type == EventTypes.RoomEncryption:
self._attempt_to_invalidate_cache(
"get_room_encryption", (data.room_id,)
)
elif data.type == EventTypes.Create:
self._attempt_to_invalidate_cache("get_room_type", (data.room_id,))
elif row.type == EventsStreamAllStateRow.TypeId: elif row.type == EventsStreamAllStateRow.TypeId:
assert isinstance(data, EventsStreamAllStateRow) assert isinstance(data, EventsStreamAllStateRow)
# Similar to the above, but the entire caches are invalidated. This is # Similar to the above, but the entire caches are invalidated. This is
# unfortunate for the membership caches, but should recover quickly. # unfortunate for the membership caches, but should recover quickly.
self._curr_state_delta_stream_cache.entity_has_changed(data.room_id, token) # type: ignore[attr-defined] self._curr_state_delta_stream_cache.entity_has_changed(data.room_id, token) # type: ignore[attr-defined]
self.get_rooms_for_user.invalidate_all() # type: ignore[attr-defined] self._attempt_to_invalidate_cache("get_rooms_for_user", None)
self._attempt_to_invalidate_cache("get_room_type", (data.room_id,))
self._attempt_to_invalidate_cache("get_room_encryption", (data.room_id,))
else: else:
raise Exception("Unknown events stream row type %s" % (row.type,)) raise Exception("Unknown events stream row type %s" % (row.type,))
@ -345,6 +355,10 @@ class CacheInvalidationWorkerStore(SQLBaseStore):
self._attempt_to_invalidate_cache( self._attempt_to_invalidate_cache(
"get_forgotten_rooms_for_user", (state_key,) "get_forgotten_rooms_for_user", (state_key,)
) )
elif etype == EventTypes.Create:
self._attempt_to_invalidate_cache("get_room_type", (room_id,))
elif etype == EventTypes.RoomEncryption:
self._attempt_to_invalidate_cache("get_room_encryption", (room_id,))
if relates_to: if relates_to:
self._attempt_to_invalidate_cache( self._attempt_to_invalidate_cache(
@ -405,6 +419,8 @@ class CacheInvalidationWorkerStore(SQLBaseStore):
self._attempt_to_invalidate_cache("get_thread_summary", None) self._attempt_to_invalidate_cache("get_thread_summary", None)
self._attempt_to_invalidate_cache("get_thread_participated", None) self._attempt_to_invalidate_cache("get_thread_participated", None)
self._attempt_to_invalidate_cache("get_threads", (room_id,)) self._attempt_to_invalidate_cache("get_threads", (room_id,))
self._attempt_to_invalidate_cache("get_room_type", (room_id,))
self._attempt_to_invalidate_cache("get_room_encryption", (room_id,))
self._attempt_to_invalidate_cache("_get_state_group_for_event", None) self._attempt_to_invalidate_cache("_get_state_group_for_event", None)
@ -457,6 +473,8 @@ class CacheInvalidationWorkerStore(SQLBaseStore):
self._attempt_to_invalidate_cache("get_forgotten_rooms_for_user", None) self._attempt_to_invalidate_cache("get_forgotten_rooms_for_user", None)
self._attempt_to_invalidate_cache("_get_membership_from_event_id", None) self._attempt_to_invalidate_cache("_get_membership_from_event_id", None)
self._attempt_to_invalidate_cache("get_room_version_id", (room_id,)) self._attempt_to_invalidate_cache("get_room_version_id", (room_id,))
self._attempt_to_invalidate_cache("get_room_type", (room_id,))
self._attempt_to_invalidate_cache("get_room_encryption", (room_id,))
# And delete state caches. # And delete state caches.

View File

@ -30,6 +30,7 @@ from typing import (
Iterable, Iterable,
List, List,
Mapping, Mapping,
MutableMapping,
Optional, Optional,
Set, Set,
Tuple, Tuple,
@ -72,10 +73,18 @@ logger = logging.getLogger(__name__)
_T = TypeVar("_T") _T = TypeVar("_T")
MAX_STATE_DELTA_HOPS = 100 MAX_STATE_DELTA_HOPS = 100
# Freeze so it's immutable and we can use it as a cache value
@attr.s(slots=True, frozen=True, auto_attribs=True)
class Sentinel:
pass
ROOM_UNKNOWN_SENTINEL = Sentinel()
@attr.s(slots=True, frozen=True, auto_attribs=True) @attr.s(slots=True, frozen=True, auto_attribs=True)
class EventMetadata: class EventMetadata:
"""Returned by `get_metadata_for_events`""" """Returned by `get_metadata_for_events`"""
@ -300,51 +309,189 @@ class StateGroupWorkerStore(EventsWorkerStore, SQLBaseStore):
@cached(max_entries=10000) @cached(max_entries=10000)
async def get_room_type(self, room_id: str) -> Optional[str]: async def get_room_type(self, room_id: str) -> Optional[str]:
"""Get the room type for a given room. The server must be joined to the raise NotImplementedError()
given room.
"""
row = await self.db_pool.simple_select_one(
table="room_stats_state",
keyvalues={"room_id": room_id},
retcols=("room_type",),
allow_none=True,
desc="get_room_type",
)
if row is not None:
return row[0]
# If we haven't updated `room_stats_state` with the room yet, query the
# create event directly.
create_event = await self.get_create_event_for_room(room_id)
room_type = create_event.content.get(EventContentFields.ROOM_TYPE)
return room_type
@cachedList(cached_method_name="get_room_type", list_name="room_ids") @cachedList(cached_method_name="get_room_type", list_name="room_ids")
async def bulk_get_room_type( async def bulk_get_room_type(
self, room_ids: Set[str] self, room_ids: Set[str]
) -> Mapping[str, Optional[str]]: ) -> Mapping[str, Union[Optional[str], Sentinel]]:
"""Bulk fetch room types for the given rooms, the server must be in all """
the rooms given. Bulk fetch room types for the given rooms (via current state).
Since this function is cached, any missing values would be cached as `None`. In
order to distinguish between an unencrypted room that has `None` encryption and
a room that is unknown to the server where we might want to omit the value
(which would make it cached as `None`), instead we use the sentinel value
`ROOM_UNKNOWN_SENTINEL`.
Returns:
A mapping from room ID to the room's type (`None` is a valid room type).
Rooms unknown to this server will return `ROOM_UNKNOWN_SENTINEL`.
""" """
rows = await self.db_pool.simple_select_many_batch( def txn(
table="room_stats_state", txn: LoggingTransaction,
column="room_id", ) -> MutableMapping[str, Union[Optional[str], Sentinel]]:
iterable=room_ids, clause, args = make_in_list_sql_clause(
retcols=("room_id", "room_type"), txn.database_engine, "room_id", room_ids
desc="bulk_get_room_type", )
# We can't rely on `room_stats_state.room_type` if the server has left the
# room because the `room_id` will still be in the table but everything will
# be set to `None` but `None` is a valid room type value. We join against
# the `room_stats_current` table which keeps track of the
# `current_state_events` count (and a proxy value `local_users_in_room`
# which can used to assume the server is participating in the room and has
# current state) to ensure that the data in `room_stats_state` is up-to-date
# with the current state.
#
# FIXME: Use `room_stats_current.current_state_events` instead of
# `room_stats_current.local_users_in_room` once
# https://github.com/element-hq/synapse/issues/17457 is fixed.
sql = f"""
SELECT room_id, room_type
FROM room_stats_state
INNER JOIN room_stats_current USING (room_id)
WHERE
{clause}
AND local_users_in_room > 0
"""
txn.execute(sql, args)
room_id_to_type_map = {}
for row in txn:
room_id_to_type_map[row[0]] = row[1]
return room_id_to_type_map
results = await self.db_pool.runInteraction(
"bulk_get_room_type",
txn,
) )
# If we haven't updated `room_stats_state` with the room yet, query the # If we haven't updated `room_stats_state` with the room yet, query the
# create events directly. This should happen only rarely so we don't # create events directly. This should happen only rarely so we don't
# mind if we do this in a loop. # mind if we do this in a loop.
results = dict(rows)
for room_id in room_ids - results.keys(): for room_id in room_ids - results.keys():
try:
create_event = await self.get_create_event_for_room(room_id) create_event = await self.get_create_event_for_room(room_id)
room_type = create_event.content.get(EventContentFields.ROOM_TYPE) room_type = create_event.content.get(EventContentFields.ROOM_TYPE)
results[room_id] = room_type results[room_id] = room_type
except NotFoundError:
# We use the sentinel value to distinguish between `None` which is a
# valid room type and a room that is unknown to the server so the value
# is just unset.
results[room_id] = ROOM_UNKNOWN_SENTINEL
return results
@cached(max_entries=10000)
async def get_room_encryption(self, room_id: str) -> Optional[str]:
raise NotImplementedError()
@cachedList(cached_method_name="get_room_encryption", list_name="room_ids")
async def bulk_get_room_encryption(
self, room_ids: Set[str]
) -> Mapping[str, Union[Optional[str], Sentinel]]:
"""
Bulk fetch room encryption for the given rooms (via current state).
Since this function is cached, any missing values would be cached as `None`. In
order to distinguish between an unencrypted room that has `None` encryption and
a room that is unknown to the server where we might want to omit the value
(which would make it cached as `None`), instead we use the sentinel value
`ROOM_UNKNOWN_SENTINEL`.
Returns:
A mapping from room ID to the room's encryption algorithm if the room is
encrypted, otherwise `None`. Rooms unknown to this server will return
`ROOM_UNKNOWN_SENTINEL`.
"""
def txn(
txn: LoggingTransaction,
) -> MutableMapping[str, Union[Optional[str], Sentinel]]:
clause, args = make_in_list_sql_clause(
txn.database_engine, "room_id", room_ids
)
# We can't rely on `room_stats_state.encryption` if the server has left the
# room because the `room_id` will still be in the table but everything will
# be set to `None` but `None` is a valid encryption value. We join against
# the `room_stats_current` table which keeps track of the
# `current_state_events` count (and a proxy value `local_users_in_room`
# which can used to assume the server is participating in the room and has
# current state) to ensure that the data in `room_stats_state` is up-to-date
# with the current state.
#
# FIXME: Use `room_stats_current.current_state_events` instead of
# `room_stats_current.local_users_in_room` once
# https://github.com/element-hq/synapse/issues/17457 is fixed.
sql = f"""
SELECT room_id, encryption
FROM room_stats_state
INNER JOIN room_stats_current USING (room_id)
WHERE
{clause}
AND local_users_in_room > 0
"""
txn.execute(sql, args)
room_id_to_encryption_map = {}
for row in txn:
room_id_to_encryption_map[row[0]] = row[1]
return room_id_to_encryption_map
results = await self.db_pool.runInteraction(
"bulk_get_room_encryption",
txn,
)
# If we haven't updated `room_stats_state` with the room yet, query the state
# directly. This should happen only rarely so we don't mind if we do this in a
# loop.
encryption_event_ids: List[str] = []
for room_id in room_ids - results.keys():
state_map = await self.get_partial_filtered_current_state_ids(
room_id,
state_filter=StateFilter.from_types(
[
(EventTypes.Create, ""),
(EventTypes.RoomEncryption, ""),
]
),
)
# We can use the create event as a canary to tell whether the server has
# seen the room before
create_event_id = state_map.get((EventTypes.Create, ""))
encryption_event_id = state_map.get((EventTypes.RoomEncryption, ""))
if create_event_id is None:
# We use the sentinel value to distinguish between `None` which is a
# valid room type and a room that is unknown to the server so the value
# is just unset.
results[room_id] = ROOM_UNKNOWN_SENTINEL
continue
if encryption_event_id is None:
results[room_id] = None
else:
encryption_event_ids.append(encryption_event_id)
encryption_event_map = await self.get_events(encryption_event_ids)
for encryption_event_id in encryption_event_ids:
encryption_event = encryption_event_map.get(encryption_event_id)
# If the curent state says there is an encryption event, we should have it
# in the database.
assert encryption_event is not None
results[encryption_event.room_id] = encryption_event.content.get(
EventContentFields.ENCRYPTION_ALGORITHM
)
return results return results

View File

@ -19,7 +19,7 @@
# #
import logging import logging
from copy import deepcopy from copy import deepcopy
from typing import Dict, Optional from typing import Dict, List, Optional
from unittest.mock import patch from unittest.mock import patch
from parameterized import parameterized from parameterized import parameterized
@ -35,7 +35,7 @@ from synapse.api.constants import (
RoomTypes, RoomTypes,
) )
from synapse.api.room_versions import RoomVersions from synapse.api.room_versions import RoomVersions
from synapse.events import make_event_from_dict from synapse.events import StrippedStateEvent, make_event_from_dict
from synapse.events.snapshot import EventContext from synapse.events.snapshot import EventContext
from synapse.handlers.sliding_sync import ( from synapse.handlers.sliding_sync import (
RoomSyncConfig, RoomSyncConfig,
@ -3093,6 +3093,78 @@ class FilterRoomsTestCase(HomeserverTestCase):
return room_id return room_id
_remote_invite_count: int = 0
def _create_remote_invite_room_for_user(
self,
invitee_user_id: str,
unsigned_invite_room_state: Optional[List[StrippedStateEvent]],
) -> str:
"""
Create a fake invite for a remote room and persist it.
We don't have any state for these kind of rooms and can only rely on the
stripped state included in the unsigned portion of the invite event to identify
the room.
Args:
invitee_user_id: The person being invited
unsigned_invite_room_state: List of stripped state events to assist the
receiver in identifying the room.
Returns:
The room ID of the remote invite room
"""
invite_room_id = f"!test_room{self._remote_invite_count}:remote_server"
invite_event_dict = {
"room_id": invite_room_id,
"sender": "@inviter:remote_server",
"state_key": invitee_user_id,
"depth": 1,
"origin_server_ts": 1,
"type": EventTypes.Member,
"content": {"membership": Membership.INVITE},
"auth_events": [],
"prev_events": [],
}
if unsigned_invite_room_state is not None:
serialized_stripped_state_events = []
for stripped_event in unsigned_invite_room_state:
serialized_stripped_state_events.append(
{
"type": stripped_event.type,
"state_key": stripped_event.state_key,
"sender": stripped_event.sender,
"content": stripped_event.content,
}
)
invite_event_dict["unsigned"] = {
"invite_room_state": serialized_stripped_state_events
}
invite_event = make_event_from_dict(
invite_event_dict,
room_version=RoomVersions.V10,
)
invite_event.internal_metadata.outlier = True
invite_event.internal_metadata.out_of_band_membership = True
self.get_success(
self.store.maybe_store_room_on_outlier_membership(
room_id=invite_room_id, room_version=invite_event.room_version
)
)
context = EventContext.for_outlier(self.hs.get_storage_controllers())
persist_controller = self.hs.get_storage_controllers().persistence
assert persist_controller is not None
self.get_success(persist_controller.persist_event(invite_event, context))
self._remote_invite_count += 1
return invite_room_id
def test_filter_dm_rooms(self) -> None: def test_filter_dm_rooms(self) -> None:
""" """
Test `filter.is_dm` for DM rooms Test `filter.is_dm` for DM rooms
@ -3157,7 +3229,7 @@ class FilterRoomsTestCase(HomeserverTestCase):
user1_id = self.register_user("user1", "pass") user1_id = self.register_user("user1", "pass")
user1_tok = self.login(user1_id, "pass") user1_tok = self.login(user1_id, "pass")
# Create a normal room # Create an unencrypted room
room_id = self.helper.create_room_as(user1_id, tok=user1_tok) room_id = self.helper.create_room_as(user1_id, tok=user1_tok)
# Create an encrypted room # Create an encrypted room
@ -3165,7 +3237,7 @@ class FilterRoomsTestCase(HomeserverTestCase):
self.helper.send_state( self.helper.send_state(
encrypted_room_id, encrypted_room_id,
EventTypes.RoomEncryption, EventTypes.RoomEncryption,
{"algorithm": "m.megolm.v1.aes-sha2"}, {EventContentFields.ENCRYPTION_ALGORITHM: "m.megolm.v1.aes-sha2"},
tok=user1_tok, tok=user1_tok,
) )
@ -3206,6 +3278,460 @@ class FilterRoomsTestCase(HomeserverTestCase):
self.assertEqual(falsy_filtered_room_map.keys(), {room_id}) self.assertEqual(falsy_filtered_room_map.keys(), {room_id})
def test_filter_encrypted_server_left_room(self) -> None:
"""
Test that we can apply a `filter.is_encrypted` against a room that everyone has left.
"""
user1_id = self.register_user("user1", "pass")
user1_tok = self.login(user1_id, "pass")
before_rooms_token = self.event_sources.get_current_token()
# Create an unencrypted room
room_id = self.helper.create_room_as(user1_id, tok=user1_tok)
# Leave the room
self.helper.leave(room_id, user1_id, tok=user1_tok)
# Create an encrypted room
encrypted_room_id = self.helper.create_room_as(user1_id, tok=user1_tok)
self.helper.send_state(
encrypted_room_id,
EventTypes.RoomEncryption,
{EventContentFields.ENCRYPTION_ALGORITHM: "m.megolm.v1.aes-sha2"},
tok=user1_tok,
)
# Leave the room
self.helper.leave(encrypted_room_id, user1_id, tok=user1_tok)
after_rooms_token = self.event_sources.get_current_token()
# Get the rooms the user should be syncing with
sync_room_map = self._get_sync_room_ids_for_user(
UserID.from_string(user1_id),
# We're using a `from_token` so that the room is considered `newly_left` and
# appears in our list of relevant sync rooms
from_token=before_rooms_token,
to_token=after_rooms_token,
)
# Try with `is_encrypted=True`
truthy_filtered_room_map = self.get_success(
self.sliding_sync_handler.filter_rooms(
UserID.from_string(user1_id),
sync_room_map,
SlidingSyncConfig.SlidingSyncList.Filters(
is_encrypted=True,
),
after_rooms_token,
)
)
self.assertEqual(truthy_filtered_room_map.keys(), {encrypted_room_id})
# Try with `is_encrypted=False`
falsy_filtered_room_map = self.get_success(
self.sliding_sync_handler.filter_rooms(
UserID.from_string(user1_id),
sync_room_map,
SlidingSyncConfig.SlidingSyncList.Filters(
is_encrypted=False,
),
after_rooms_token,
)
)
self.assertEqual(falsy_filtered_room_map.keys(), {room_id})
def test_filter_encrypted_server_left_room2(self) -> None:
"""
Test that we can apply a `filter.is_encrypted` against a room that everyone has
left.
There is still someone local who is invited to the rooms but that doesn't affect
whether the server is participating in the room (users need to be joined).
"""
user1_id = self.register_user("user1", "pass")
user1_tok = self.login(user1_id, "pass")
user2_id = self.register_user("user2", "pass")
_user2_tok = self.login(user2_id, "pass")
before_rooms_token = self.event_sources.get_current_token()
# Create an unencrypted room
room_id = self.helper.create_room_as(user1_id, tok=user1_tok)
# Invite user2
self.helper.invite(room_id, targ=user2_id, tok=user1_tok)
# User1 leaves the room
self.helper.leave(room_id, user1_id, tok=user1_tok)
# Create an encrypted room
encrypted_room_id = self.helper.create_room_as(user1_id, tok=user1_tok)
self.helper.send_state(
encrypted_room_id,
EventTypes.RoomEncryption,
{EventContentFields.ENCRYPTION_ALGORITHM: "m.megolm.v1.aes-sha2"},
tok=user1_tok,
)
# Invite user2
self.helper.invite(encrypted_room_id, targ=user2_id, tok=user1_tok)
# User1 leaves the room
self.helper.leave(encrypted_room_id, user1_id, tok=user1_tok)
after_rooms_token = self.event_sources.get_current_token()
# Get the rooms the user should be syncing with
sync_room_map = self._get_sync_room_ids_for_user(
UserID.from_string(user1_id),
# We're using a `from_token` so that the room is considered `newly_left` and
# appears in our list of relevant sync rooms
from_token=before_rooms_token,
to_token=after_rooms_token,
)
# Try with `is_encrypted=True`
truthy_filtered_room_map = self.get_success(
self.sliding_sync_handler.filter_rooms(
UserID.from_string(user1_id),
sync_room_map,
SlidingSyncConfig.SlidingSyncList.Filters(
is_encrypted=True,
),
after_rooms_token,
)
)
self.assertEqual(truthy_filtered_room_map.keys(), {encrypted_room_id})
# Try with `is_encrypted=False`
falsy_filtered_room_map = self.get_success(
self.sliding_sync_handler.filter_rooms(
UserID.from_string(user1_id),
sync_room_map,
SlidingSyncConfig.SlidingSyncList.Filters(
is_encrypted=False,
),
after_rooms_token,
)
)
self.assertEqual(falsy_filtered_room_map.keys(), {room_id})
def test_filter_encrypted_after_we_left(self) -> None:
"""
Test that we can apply a `filter.is_encrypted` against a room that was encrypted
after we left the room (make sure we don't just use the current state)
"""
user1_id = self.register_user("user1", "pass")
user1_tok = self.login(user1_id, "pass")
user2_id = self.register_user("user2", "pass")
user2_tok = self.login(user2_id, "pass")
before_rooms_token = self.event_sources.get_current_token()
# Create an unencrypted room
room_id = self.helper.create_room_as(user2_id, tok=user2_tok)
# Leave the room
self.helper.join(room_id, user1_id, tok=user1_tok)
self.helper.leave(room_id, user1_id, tok=user1_tok)
# Create a room that will be encrypted
encrypted_after_we_left_room_id = self.helper.create_room_as(
user2_id, tok=user2_tok
)
# Leave the room
self.helper.join(encrypted_after_we_left_room_id, user1_id, tok=user1_tok)
self.helper.leave(encrypted_after_we_left_room_id, user1_id, tok=user1_tok)
# Encrypt the room after we've left
self.helper.send_state(
encrypted_after_we_left_room_id,
EventTypes.RoomEncryption,
{EventContentFields.ENCRYPTION_ALGORITHM: "m.megolm.v1.aes-sha2"},
tok=user2_tok,
)
after_rooms_token = self.event_sources.get_current_token()
# Get the rooms the user should be syncing with
sync_room_map = self._get_sync_room_ids_for_user(
UserID.from_string(user1_id),
# We're using a `from_token` so that the room is considered `newly_left` and
# appears in our list of relevant sync rooms
from_token=before_rooms_token,
to_token=after_rooms_token,
)
# Try with `is_encrypted=True`
truthy_filtered_room_map = self.get_success(
self.sliding_sync_handler.filter_rooms(
UserID.from_string(user1_id),
sync_room_map,
SlidingSyncConfig.SlidingSyncList.Filters(
is_encrypted=True,
),
after_rooms_token,
)
)
# Even though we left the room before it was encrypted, we still see it because
# someone else on our server is still participating in the room and we "leak"
# the current state to the left user. But we consider the room encryption status
# to not be a secret given it's often set at the start of the room and it's one
# of the stripped state events that is normally handed out.
self.assertEqual(
truthy_filtered_room_map.keys(), {encrypted_after_we_left_room_id}
)
# Try with `is_encrypted=False`
falsy_filtered_room_map = self.get_success(
self.sliding_sync_handler.filter_rooms(
UserID.from_string(user1_id),
sync_room_map,
SlidingSyncConfig.SlidingSyncList.Filters(
is_encrypted=False,
),
after_rooms_token,
)
)
# Even though we left the room before it was encrypted... (see comment above)
self.assertEqual(falsy_filtered_room_map.keys(), {room_id})
def test_filter_encrypted_with_remote_invite_room_no_stripped_state(self) -> None:
"""
Test that we can apply a `filter.is_encrypted` filter against a remote invite
room without any `unsigned.invite_room_state` (stripped state).
"""
user1_id = self.register_user("user1", "pass")
user1_tok = self.login(user1_id, "pass")
# Create a remote invite room without any `unsigned.invite_room_state`
_remote_invite_room_id = self._create_remote_invite_room_for_user(
user1_id, None
)
# Create an unencrypted room
room_id = self.helper.create_room_as(user1_id, tok=user1_tok)
# Create an encrypted room
encrypted_room_id = self.helper.create_room_as(user1_id, tok=user1_tok)
self.helper.send_state(
encrypted_room_id,
EventTypes.RoomEncryption,
{EventContentFields.ENCRYPTION_ALGORITHM: "m.megolm.v1.aes-sha2"},
tok=user1_tok,
)
after_rooms_token = self.event_sources.get_current_token()
# Get the rooms the user should be syncing with
sync_room_map = self._get_sync_room_ids_for_user(
UserID.from_string(user1_id),
from_token=None,
to_token=after_rooms_token,
)
# Try with `is_encrypted=True`
truthy_filtered_room_map = self.get_success(
self.sliding_sync_handler.filter_rooms(
UserID.from_string(user1_id),
sync_room_map,
SlidingSyncConfig.SlidingSyncList.Filters(
is_encrypted=True,
),
after_rooms_token,
)
)
# `remote_invite_room_id` should not appear because we can't figure out whether
# it is encrypted or not (no stripped state, `unsigned.invite_room_state`).
self.assertEqual(truthy_filtered_room_map.keys(), {encrypted_room_id})
# Try with `is_encrypted=False`
falsy_filtered_room_map = self.get_success(
self.sliding_sync_handler.filter_rooms(
UserID.from_string(user1_id),
sync_room_map,
SlidingSyncConfig.SlidingSyncList.Filters(
is_encrypted=False,
),
after_rooms_token,
)
)
# `remote_invite_room_id` should not appear because we can't figure out whether
# it is encrypted or not (no stripped state, `unsigned.invite_room_state`).
self.assertEqual(falsy_filtered_room_map.keys(), {room_id})
def test_filter_encrypted_with_remote_invite_encrypted_room(self) -> None:
"""
Test that we can apply a `filter.is_encrypted` filter against a remote invite
encrypted room with some `unsigned.invite_room_state` (stripped state).
"""
user1_id = self.register_user("user1", "pass")
user1_tok = self.login(user1_id, "pass")
# Create a remote invite room with some `unsigned.invite_room_state`
# indicating that the room is encrypted.
remote_invite_room_id = self._create_remote_invite_room_for_user(
user1_id,
[
StrippedStateEvent(
type=EventTypes.Create,
state_key="",
sender="@inviter:remote_server",
content={
EventContentFields.ROOM_CREATOR: "@inviter:remote_server",
EventContentFields.ROOM_VERSION: RoomVersions.V10.identifier,
},
),
StrippedStateEvent(
type=EventTypes.RoomEncryption,
state_key="",
sender="@inviter:remote_server",
content={
EventContentFields.ENCRYPTION_ALGORITHM: "m.megolm.v1.aes-sha2",
},
),
],
)
# Create an unencrypted room
room_id = self.helper.create_room_as(user1_id, tok=user1_tok)
# Create an encrypted room
encrypted_room_id = self.helper.create_room_as(user1_id, tok=user1_tok)
self.helper.send_state(
encrypted_room_id,
EventTypes.RoomEncryption,
{EventContentFields.ENCRYPTION_ALGORITHM: "m.megolm.v1.aes-sha2"},
tok=user1_tok,
)
after_rooms_token = self.event_sources.get_current_token()
# Get the rooms the user should be syncing with
sync_room_map = self._get_sync_room_ids_for_user(
UserID.from_string(user1_id),
from_token=None,
to_token=after_rooms_token,
)
# Try with `is_encrypted=True`
truthy_filtered_room_map = self.get_success(
self.sliding_sync_handler.filter_rooms(
UserID.from_string(user1_id),
sync_room_map,
SlidingSyncConfig.SlidingSyncList.Filters(
is_encrypted=True,
),
after_rooms_token,
)
)
# `remote_invite_room_id` should appear here because it is encrypted
# according to the stripped state
self.assertEqual(
truthy_filtered_room_map.keys(), {encrypted_room_id, remote_invite_room_id}
)
# Try with `is_encrypted=False`
falsy_filtered_room_map = self.get_success(
self.sliding_sync_handler.filter_rooms(
UserID.from_string(user1_id),
sync_room_map,
SlidingSyncConfig.SlidingSyncList.Filters(
is_encrypted=False,
),
after_rooms_token,
)
)
# `remote_invite_room_id` should not appear here because it is encrypted
# according to the stripped state
self.assertEqual(falsy_filtered_room_map.keys(), {room_id})
def test_filter_encrypted_with_remote_invite_unencrypted_room(self) -> None:
"""
Test that we can apply a `filter.is_encrypted` filter against a remote invite
unencrypted room with some `unsigned.invite_room_state` (stripped state).
"""
user1_id = self.register_user("user1", "pass")
user1_tok = self.login(user1_id, "pass")
# Create a remote invite room with some `unsigned.invite_room_state`
# but don't set any room encryption event.
remote_invite_room_id = self._create_remote_invite_room_for_user(
user1_id,
[
StrippedStateEvent(
type=EventTypes.Create,
state_key="",
sender="@inviter:remote_server",
content={
EventContentFields.ROOM_CREATOR: "@inviter:remote_server",
EventContentFields.ROOM_VERSION: RoomVersions.V10.identifier,
},
),
# No room encryption event
],
)
# Create an unencrypted room
room_id = self.helper.create_room_as(user1_id, tok=user1_tok)
# Create an encrypted room
encrypted_room_id = self.helper.create_room_as(user1_id, tok=user1_tok)
self.helper.send_state(
encrypted_room_id,
EventTypes.RoomEncryption,
{EventContentFields.ENCRYPTION_ALGORITHM: "m.megolm.v1.aes-sha2"},
tok=user1_tok,
)
after_rooms_token = self.event_sources.get_current_token()
# Get the rooms the user should be syncing with
sync_room_map = self._get_sync_room_ids_for_user(
UserID.from_string(user1_id),
from_token=None,
to_token=after_rooms_token,
)
# Try with `is_encrypted=True`
truthy_filtered_room_map = self.get_success(
self.sliding_sync_handler.filter_rooms(
UserID.from_string(user1_id),
sync_room_map,
SlidingSyncConfig.SlidingSyncList.Filters(
is_encrypted=True,
),
after_rooms_token,
)
)
# `remote_invite_room_id` should not appear here because it is unencrypted
# according to the stripped state
self.assertEqual(truthy_filtered_room_map.keys(), {encrypted_room_id})
# Try with `is_encrypted=False`
falsy_filtered_room_map = self.get_success(
self.sliding_sync_handler.filter_rooms(
UserID.from_string(user1_id),
sync_room_map,
SlidingSyncConfig.SlidingSyncList.Filters(
is_encrypted=False,
),
after_rooms_token,
)
)
# `remote_invite_room_id` should appear because it is unencrypted according to
# the stripped state
self.assertEqual(
falsy_filtered_room_map.keys(), {room_id, remote_invite_room_id}
)
def test_filter_invite_rooms(self) -> None: def test_filter_invite_rooms(self) -> None:
""" """
Test `filter.is_invite` for rooms that the user has been invited to Test `filter.is_invite` for rooms that the user has been invited to
@ -3461,47 +3987,159 @@ class FilterRoomsTestCase(HomeserverTestCase):
self.assertEqual(filtered_room_map.keys(), {space_room_id}) self.assertEqual(filtered_room_map.keys(), {space_room_id})
def test_filter_room_types_with_invite_remote_room(self) -> None: def test_filter_room_types_server_left_room(self) -> None:
"""Test that we can apply a room type filter, even if we have an invite """
for a remote room. Test that we can apply a `filter.room_types` against a room that everyone has left.
This is a regression test.
""" """
user1_id = self.register_user("user1", "pass") user1_id = self.register_user("user1", "pass")
user1_tok = self.login(user1_id, "pass") user1_tok = self.login(user1_id, "pass")
# Create a fake remote invite and persist it. before_rooms_token = self.event_sources.get_current_token()
invite_room_id = "!some:room"
invite_event = make_event_from_dict(
{
"room_id": invite_room_id,
"sender": "@user:test.serv",
"state_key": user1_id,
"depth": 1,
"origin_server_ts": 1,
"type": EventTypes.Member,
"content": {"membership": Membership.INVITE},
"auth_events": [],
"prev_events": [],
},
room_version=RoomVersions.V10,
)
invite_event.internal_metadata.outlier = True
invite_event.internal_metadata.out_of_band_membership = True
self.get_success(
self.store.maybe_store_room_on_outlier_membership(
room_id=invite_room_id, room_version=invite_event.room_version
)
)
context = EventContext.for_outlier(self.hs.get_storage_controllers())
persist_controller = self.hs.get_storage_controllers().persistence
assert persist_controller is not None
self.get_success(persist_controller.persist_event(invite_event, context))
# Create a normal room (no room type) # Create a normal room (no room type)
room_id = self.helper.create_room_as(user1_id, tok=user1_tok) room_id = self.helper.create_room_as(user1_id, tok=user1_tok)
# Leave the room
self.helper.leave(room_id, user1_id, tok=user1_tok)
# Create a space room
space_room_id = self.helper.create_room_as(
user1_id,
tok=user1_tok,
extra_content={
"creation_content": {EventContentFields.ROOM_TYPE: RoomTypes.SPACE}
},
)
# Leave the room
self.helper.leave(space_room_id, user1_id, tok=user1_tok)
after_rooms_token = self.event_sources.get_current_token()
# Get the rooms the user should be syncing with
sync_room_map = self._get_sync_room_ids_for_user(
UserID.from_string(user1_id),
# We're using a `from_token` so that the room is considered `newly_left` and
# appears in our list of relevant sync rooms
from_token=before_rooms_token,
to_token=after_rooms_token,
)
# Try finding only normal rooms
filtered_room_map = self.get_success(
self.sliding_sync_handler.filter_rooms(
UserID.from_string(user1_id),
sync_room_map,
SlidingSyncConfig.SlidingSyncList.Filters(room_types=[None]),
after_rooms_token,
)
)
self.assertEqual(filtered_room_map.keys(), {room_id})
# Try finding only spaces
filtered_room_map = self.get_success(
self.sliding_sync_handler.filter_rooms(
UserID.from_string(user1_id),
sync_room_map,
SlidingSyncConfig.SlidingSyncList.Filters(room_types=[RoomTypes.SPACE]),
after_rooms_token,
)
)
self.assertEqual(filtered_room_map.keys(), {space_room_id})
def test_filter_room_types_server_left_room2(self) -> None:
"""
Test that we can apply a `filter.room_types` against a room that everyone has left.
There is still someone local who is invited to the rooms but that doesn't affect
whether the server is participating in the room (users need to be joined).
"""
user1_id = self.register_user("user1", "pass")
user1_tok = self.login(user1_id, "pass")
user2_id = self.register_user("user2", "pass")
_user2_tok = self.login(user2_id, "pass")
before_rooms_token = self.event_sources.get_current_token()
# Create a normal room (no room type)
room_id = self.helper.create_room_as(user1_id, tok=user1_tok)
# Invite user2
self.helper.invite(room_id, targ=user2_id, tok=user1_tok)
# User1 leaves the room
self.helper.leave(room_id, user1_id, tok=user1_tok)
# Create a space room
space_room_id = self.helper.create_room_as(
user1_id,
tok=user1_tok,
extra_content={
"creation_content": {EventContentFields.ROOM_TYPE: RoomTypes.SPACE}
},
)
# Invite user2
self.helper.invite(space_room_id, targ=user2_id, tok=user1_tok)
# User1 leaves the room
self.helper.leave(space_room_id, user1_id, tok=user1_tok)
after_rooms_token = self.event_sources.get_current_token()
# Get the rooms the user should be syncing with
sync_room_map = self._get_sync_room_ids_for_user(
UserID.from_string(user1_id),
# We're using a `from_token` so that the room is considered `newly_left` and
# appears in our list of relevant sync rooms
from_token=before_rooms_token,
to_token=after_rooms_token,
)
# Try finding only normal rooms
filtered_room_map = self.get_success(
self.sliding_sync_handler.filter_rooms(
UserID.from_string(user1_id),
sync_room_map,
SlidingSyncConfig.SlidingSyncList.Filters(room_types=[None]),
after_rooms_token,
)
)
self.assertEqual(filtered_room_map.keys(), {room_id})
# Try finding only spaces
filtered_room_map = self.get_success(
self.sliding_sync_handler.filter_rooms(
UserID.from_string(user1_id),
sync_room_map,
SlidingSyncConfig.SlidingSyncList.Filters(room_types=[RoomTypes.SPACE]),
after_rooms_token,
)
)
self.assertEqual(filtered_room_map.keys(), {space_room_id})
def test_filter_room_types_with_remote_invite_room_no_stripped_state(self) -> None:
"""
Test that we can apply a `filter.room_types` filter against a remote invite
room without any `unsigned.invite_room_state` (stripped state).
"""
user1_id = self.register_user("user1", "pass")
user1_tok = self.login(user1_id, "pass")
# Create a remote invite room without any `unsigned.invite_room_state`
_remote_invite_room_id = self._create_remote_invite_room_for_user(
user1_id, None
)
# Create a normal room (no room type)
room_id = self.helper.create_room_as(user1_id, tok=user1_tok)
# Create a space room
space_room_id = self.helper.create_room_as(
user1_id,
tok=user1_tok,
extra_content={
"creation_content": {EventContentFields.ROOM_TYPE: RoomTypes.SPACE}
},
)
after_rooms_token = self.event_sources.get_current_token() after_rooms_token = self.event_sources.get_current_token()
@ -3512,18 +4150,186 @@ class FilterRoomsTestCase(HomeserverTestCase):
to_token=after_rooms_token, to_token=after_rooms_token,
) )
# Try finding only normal rooms
filtered_room_map = self.get_success( filtered_room_map = self.get_success(
self.sliding_sync_handler.filter_rooms( self.sliding_sync_handler.filter_rooms(
UserID.from_string(user1_id), UserID.from_string(user1_id),
sync_room_map, sync_room_map,
SlidingSyncConfig.SlidingSyncList.Filters( SlidingSyncConfig.SlidingSyncList.Filters(room_types=[None]),
room_types=[None, RoomTypes.SPACE],
),
after_rooms_token, after_rooms_token,
) )
) )
self.assertEqual(filtered_room_map.keys(), {room_id, invite_room_id}) # `remote_invite_room_id` should not appear because we can't figure out what
# room type it is (no stripped state, `unsigned.invite_room_state`)
self.assertEqual(filtered_room_map.keys(), {room_id})
# Try finding only spaces
filtered_room_map = self.get_success(
self.sliding_sync_handler.filter_rooms(
UserID.from_string(user1_id),
sync_room_map,
SlidingSyncConfig.SlidingSyncList.Filters(room_types=[RoomTypes.SPACE]),
after_rooms_token,
)
)
# `remote_invite_room_id` should not appear because we can't figure out what
# room type it is (no stripped state, `unsigned.invite_room_state`)
self.assertEqual(filtered_room_map.keys(), {space_room_id})
def test_filter_room_types_with_remote_invite_space(self) -> None:
"""
Test that we can apply a `filter.room_types` filter against a remote invite
to a space room with some `unsigned.invite_room_state` (stripped state).
"""
user1_id = self.register_user("user1", "pass")
user1_tok = self.login(user1_id, "pass")
# Create a remote invite room with some `unsigned.invite_room_state` indicating
# that it is a space room
remote_invite_room_id = self._create_remote_invite_room_for_user(
user1_id,
[
StrippedStateEvent(
type=EventTypes.Create,
state_key="",
sender="@inviter:remote_server",
content={
EventContentFields.ROOM_CREATOR: "@inviter:remote_server",
EventContentFields.ROOM_VERSION: RoomVersions.V10.identifier,
# Specify that it is a space room
EventContentFields.ROOM_TYPE: RoomTypes.SPACE,
},
),
],
)
# Create a normal room (no room type)
room_id = self.helper.create_room_as(user1_id, tok=user1_tok)
# Create a space room
space_room_id = self.helper.create_room_as(
user1_id,
tok=user1_tok,
extra_content={
"creation_content": {EventContentFields.ROOM_TYPE: RoomTypes.SPACE}
},
)
after_rooms_token = self.event_sources.get_current_token()
# Get the rooms the user should be syncing with
sync_room_map = self._get_sync_room_ids_for_user(
UserID.from_string(user1_id),
from_token=None,
to_token=after_rooms_token,
)
# Try finding only normal rooms
filtered_room_map = self.get_success(
self.sliding_sync_handler.filter_rooms(
UserID.from_string(user1_id),
sync_room_map,
SlidingSyncConfig.SlidingSyncList.Filters(room_types=[None]),
after_rooms_token,
)
)
# `remote_invite_room_id` should not appear here because it is a space room
# according to the stripped state
self.assertEqual(filtered_room_map.keys(), {room_id})
# Try finding only spaces
filtered_room_map = self.get_success(
self.sliding_sync_handler.filter_rooms(
UserID.from_string(user1_id),
sync_room_map,
SlidingSyncConfig.SlidingSyncList.Filters(room_types=[RoomTypes.SPACE]),
after_rooms_token,
)
)
# `remote_invite_room_id` should appear here because it is a space room
# according to the stripped state
self.assertEqual(
filtered_room_map.keys(), {space_room_id, remote_invite_room_id}
)
def test_filter_room_types_with_remote_invite_normal_room(self) -> None:
"""
Test that we can apply a `filter.room_types` filter against a remote invite
to a normal room with some `unsigned.invite_room_state` (stripped state).
"""
user1_id = self.register_user("user1", "pass")
user1_tok = self.login(user1_id, "pass")
# Create a remote invite room with some `unsigned.invite_room_state`
# but the create event does not specify a room type (normal room)
remote_invite_room_id = self._create_remote_invite_room_for_user(
user1_id,
[
StrippedStateEvent(
type=EventTypes.Create,
state_key="",
sender="@inviter:remote_server",
content={
EventContentFields.ROOM_CREATOR: "@inviter:remote_server",
EventContentFields.ROOM_VERSION: RoomVersions.V10.identifier,
# No room type means this is a normal room
},
),
],
)
# Create a normal room (no room type)
room_id = self.helper.create_room_as(user1_id, tok=user1_tok)
# Create a space room
space_room_id = self.helper.create_room_as(
user1_id,
tok=user1_tok,
extra_content={
"creation_content": {EventContentFields.ROOM_TYPE: RoomTypes.SPACE}
},
)
after_rooms_token = self.event_sources.get_current_token()
# Get the rooms the user should be syncing with
sync_room_map = self._get_sync_room_ids_for_user(
UserID.from_string(user1_id),
from_token=None,
to_token=after_rooms_token,
)
# Try finding only normal rooms
filtered_room_map = self.get_success(
self.sliding_sync_handler.filter_rooms(
UserID.from_string(user1_id),
sync_room_map,
SlidingSyncConfig.SlidingSyncList.Filters(room_types=[None]),
after_rooms_token,
)
)
# `remote_invite_room_id` should appear here because it is a normal room
# according to the stripped state (no room type)
self.assertEqual(filtered_room_map.keys(), {room_id, remote_invite_room_id})
# Try finding only spaces
filtered_room_map = self.get_success(
self.sliding_sync_handler.filter_rooms(
UserID.from_string(user1_id),
sync_room_map,
SlidingSyncConfig.SlidingSyncList.Filters(room_types=[RoomTypes.SPACE]),
after_rooms_token,
)
)
# `remote_invite_room_id` should not appear here because it is a normal room
# according to the stripped state (no room type)
self.assertEqual(filtered_room_map.keys(), {space_room_id})
class SortRoomsTestCase(HomeserverTestCase): class SortRoomsTestCase(HomeserverTestCase):

View File

@ -37,6 +37,7 @@ from synapse.api.constants import (
Membership, Membership,
ReceiptTypes, ReceiptTypes,
RelationTypes, RelationTypes,
RoomTypes,
) )
from synapse.api.room_versions import RoomVersions from synapse.api.room_versions import RoomVersions
from synapse.events import EventBase from synapse.events import EventBase
@ -1850,6 +1851,150 @@ class SlidingSyncTestCase(SlidingSyncBase):
}, },
) )
def test_filter_regardless_of_membership_server_left_room(self) -> None:
"""
Test that filters apply to rooms regardless of membership. We're also
compounding the problem by having all of the local users leave the room causing
our server to leave the room.
We want to make sure that if someone is filtering rooms, and leaves, you still
get that final update down sync that you left.
"""
user1_id = self.register_user("user1", "pass")
user1_tok = self.login(user1_id, "pass")
user2_id = self.register_user("user2", "pass")
user2_tok = self.login(user2_id, "pass")
# Create a normal room
room_id = self.helper.create_room_as(user1_id, tok=user2_tok)
self.helper.join(room_id, user1_id, tok=user1_tok)
# Create an encrypted space room
space_room_id = self.helper.create_room_as(
user2_id,
tok=user2_tok,
extra_content={
"creation_content": {EventContentFields.ROOM_TYPE: RoomTypes.SPACE}
},
)
self.helper.send_state(
space_room_id,
EventTypes.RoomEncryption,
{EventContentFields.ENCRYPTION_ALGORITHM: "m.megolm.v1.aes-sha2"},
tok=user2_tok,
)
self.helper.join(space_room_id, user1_id, tok=user1_tok)
# Make an initial Sliding Sync request
channel = self.make_request(
"POST",
self.sync_endpoint,
{
"lists": {
"all-list": {
"ranges": [[0, 99]],
"required_state": [],
"timeline_limit": 0,
"filters": {},
},
"foo-list": {
"ranges": [[0, 99]],
"required_state": [],
"timeline_limit": 1,
"filters": {
"is_encrypted": True,
"room_types": [RoomTypes.SPACE],
},
},
}
},
access_token=user1_tok,
)
self.assertEqual(channel.code, 200, channel.json_body)
from_token = channel.json_body["pos"]
# Make sure the response has the lists we requested
self.assertListEqual(
list(channel.json_body["lists"].keys()),
["all-list", "foo-list"],
channel.json_body["lists"].keys(),
)
# Make sure the lists have the correct rooms
self.assertListEqual(
list(channel.json_body["lists"]["all-list"]["ops"]),
[
{
"op": "SYNC",
"range": [0, 99],
"room_ids": [space_room_id, room_id],
}
],
)
self.assertListEqual(
list(channel.json_body["lists"]["foo-list"]["ops"]),
[
{
"op": "SYNC",
"range": [0, 99],
"room_ids": [space_room_id],
}
],
)
# Everyone leaves the encrypted space room
self.helper.leave(space_room_id, user1_id, tok=user1_tok)
self.helper.leave(space_room_id, user2_id, tok=user2_tok)
# Make an incremental Sliding Sync request
channel = self.make_request(
"POST",
self.sync_endpoint + f"?pos={from_token}",
{
"lists": {
"all-list": {
"ranges": [[0, 99]],
"required_state": [],
"timeline_limit": 0,
"filters": {},
},
"foo-list": {
"ranges": [[0, 99]],
"required_state": [],
"timeline_limit": 1,
"filters": {
"is_encrypted": True,
"room_types": [RoomTypes.SPACE],
},
},
}
},
access_token=user1_tok,
)
self.assertEqual(channel.code, 200, channel.json_body)
# Make sure the lists have the correct rooms even though we `newly_left`
self.assertListEqual(
list(channel.json_body["lists"]["all-list"]["ops"]),
[
{
"op": "SYNC",
"range": [0, 99],
"room_ids": [space_room_id, room_id],
}
],
)
self.assertListEqual(
list(channel.json_body["lists"]["foo-list"]["ops"]),
[
{
"op": "SYNC",
"range": [0, 99],
"room_ids": [space_room_id],
}
],
)
def test_sort_list(self) -> None: def test_sort_list(self) -> None:
""" """
Test that the `lists` are sorted by `stream_ordering` Test that the `lists` are sorted by `stream_ordering`