Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix join being denied after being invited over federation #18075

Open
wants to merge 46 commits into
base: develop
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 40 commits
Commits
Show all changes
46 commits
Select commit Hold shift + click to select a range
e4c3e7b
Add membership to event representation when logging
MadLittleMods Jan 10, 2025
d31ff53
Debug logging
MadLittleMods Jan 10, 2025
1d64beb
A couple more debug logs
MadLittleMods Jan 10, 2025
a32c1ba
Pipe store access to `check_state_dependent_auth_rules`
MadLittleMods Jan 10, 2025
9a35155
Revert "Pipe store access to `check_state_dependent_auth_rules`"
MadLittleMods Jan 14, 2025
825b249
Add out-of-band membership to auth events when creating new events
MadLittleMods Jan 14, 2025
0698dcf
Add changelog
MadLittleMods Jan 14, 2025
8c6b294
Clean up fix
MadLittleMods Jan 14, 2025
e4dad14
Merge branch 'develop' into madlittlemods/debug-synapse-consistency-i…
MadLittleMods Jan 14, 2025
7e60ec0
It's fine to notify about out-of-band membership
MadLittleMods Jan 14, 2025
ad8ed0c
Remove duplicated code
MadLittleMods Jan 14, 2025
d0639e4
Restore `notifier.notify_replication()`
MadLittleMods Jan 14, 2025
7855892
Log all handled events
MadLittleMods Jan 14, 2025
b3bda0e
Clean up some pre-existing tests
MadLittleMods Jan 14, 2025
0d01cda
Try adding out of band federation trial tests
MadLittleMods Jan 15, 2025
83237f7
User1 can join remote room
MadLittleMods Jan 15, 2025
1779d76
Move `DeviceListResyncTestCase` to their own file
MadLittleMods Jan 15, 2025
4f0cf80
Move `StripUnsignedFromEventsTestCase` to a more specific file
MadLittleMods Jan 15, 2025
741f256
Fix extremities typo
MadLittleMods Jan 15, 2025
a736ead
More robust test timeout
MadLittleMods Jan 15, 2025
70cbff8
Clean up test
MadLittleMods Jan 15, 2025
9716478
More cleanup
MadLittleMods Jan 15, 2025
05c875c
Better explanations
MadLittleMods Jan 15, 2025
0a757b6
Reset the mocks when we don't need them anymore
MadLittleMods Jan 15, 2025
d9bf5a9
Add test that reproduces the problem
MadLittleMods Jan 15, 2025
f885575
Add tests for accepting and rejecting the invite
MadLittleMods Jan 15, 2025
87cfe87
Try to explain regression test
MadLittleMods Jan 15, 2025
33ac6c9
Harden up the tests
MadLittleMods Jan 16, 2025
0d871b6
More hardening
MadLittleMods Jan 16, 2025
2fbc2fa
Allow events to auth correctly (computed state matches our auth events)
MadLittleMods Jan 17, 2025
bbeb68a
Debug logs to figure out why rejected
MadLittleMods Jan 17, 2025
11d9970
Revert "Debug logs to figure out why rejected"
MadLittleMods Jan 17, 2025
872f717
Remove debug logs
MadLittleMods Jan 18, 2025
d85d84c
Clean up whitespace
MadLittleMods Jan 18, 2025
14dc54b
Unable to reproduce to with knocks
MadLittleMods Jan 18, 2025
139db20
Bump db calls
MadLittleMods Jan 18, 2025
78bee3d
Fix presence tests
MadLittleMods Jan 18, 2025
074483d
Fix sync join/ban race test failing
MadLittleMods Jan 18, 2025
545f22d
Fix lints
MadLittleMods Jan 18, 2025
0b31100
Fix federation sender shard tests
MadLittleMods Jan 18, 2025
27b7c68
Merge branch 'develop' into madlittlemods/debug-synapse-consistency-i…
MadLittleMods Jan 24, 2025
d80984e
Better `user_id` parameter for `is_mine_id`
MadLittleMods Jan 24, 2025
ab098d9
Fix some typos
MadLittleMods Jan 24, 2025
2d5d246
Assert instead of nest
MadLittleMods Jan 24, 2025
dc547d6
Restore log
MadLittleMods Jan 24, 2025
e9ee86a
Explain why not using `spec=MatrixFederationHttpClient`
MadLittleMods Jan 24, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/18075.bugfix
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Fix join being denied after being invited over federation. Also fixes other out-of-band membership transitions.
4 changes: 3 additions & 1 deletion synapse/event_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -566,6 +566,7 @@ def _is_membership_change_allowed(
logger.debug(
"_is_membership_change_allowed: %s",
{
"caller_membership": caller.membership if caller else None,
anoadragon453 marked this conversation as resolved.
Show resolved Hide resolved
"caller_in_room": caller_in_room,
"caller_invited": caller_invited,
"caller_knocked": caller_knocked,
Expand Down Expand Up @@ -677,7 +678,8 @@ def _is_membership_change_allowed(
and join_rule == JoinRules.KNOCK_RESTRICTED
)
):
if not caller_in_room and not caller_invited:
# You can only join the room if you are invited or are already in the room.
if not (caller_in_room or caller_invited):
anoadragon453 marked this conversation as resolved.
Show resolved Hide resolved
raise AuthError(403, "You are not invited to this room.")
else:
# TODO (erikj): may_join list
Expand Down
7 changes: 6 additions & 1 deletion synapse/events/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@
from typing_extensions import Literal
from unpaddedbase64 import encode_base64

from synapse.api.constants import RelationTypes
from synapse.api.constants import EventTypes, RelationTypes
from synapse.api.room_versions import EventFormatVersions, RoomVersion, RoomVersions
from synapse.synapse_rust.events import EventInternalMetadata
from synapse.types import JsonDict, StrCollection
Expand Down Expand Up @@ -325,12 +325,17 @@ def __str__(self) -> str:
def __repr__(self) -> str:
rejection = f"REJECTED={self.rejected_reason}, " if self.rejected_reason else ""

conditional_membership_string = ""
if self.get("type") == EventTypes.Member:
conditional_membership_string = f"membership={self.membership}, "
anoadragon453 marked this conversation as resolved.
Show resolved Hide resolved

return (
f"<{self.__class__.__name__} "
f"{rejection}"
f"event_id={self.event_id}, "
f"type={self.get('type')}, "
f"state_key={self.get('state_key')}, "
f"{conditional_membership_string}"
f"outlier={self.internal_metadata.is_outlier()}"
">"
)
Expand Down
55 changes: 54 additions & 1 deletion synapse/events/builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
import attr
from signedjson.types import SigningKey

from synapse.api.constants import MAX_DEPTH
from synapse.api.constants import MAX_DEPTH, EventTypes
from synapse.api.room_versions import (
KNOWN_EVENT_FORMAT_VERSIONS,
EventFormatVersions,
Expand Down Expand Up @@ -109,6 +109,19 @@ def state_key(self) -> str:
def is_state(self) -> bool:
return self._state_key is not None

def is_mine_id(self, string: str) -> bool:
"""Determines whether a user ID or room alias originates from this homeserver.

Returns:
`True` if the hostname part of the user ID or room alias matches this
homeserver.
`False` otherwise, or if the user ID or room alias is malformed.
"""
localpart_hostname = string.split(":", 1)
MadLittleMods marked this conversation as resolved.
Show resolved Hide resolved
if len(localpart_hostname) < 2:
return False
return localpart_hostname[1] == self._hostname
Copy link
Contributor Author

@MadLittleMods MadLittleMods Jan 18, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Normally, we would access hs.is_mine_id(...) but we don't have easy access to hs here. Better way than to duplicate this?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ideally we'd have a read-only reference to hs passed to this class - but I'm not sure how easy that is.

The duplication here isn't the end of the world - given it's a small function.


async def build(
self,
prev_event_ids: List[str],
Expand Down Expand Up @@ -142,6 +155,46 @@ async def build(
self, state_ids
)

# Check for out-of-band membership that may that may have been exposed on
MadLittleMods marked this conversation as resolved.
Show resolved Hide resolved
# `/sync` but the events have not been de-outliered yet so they won't be
# part of the room state yet.
#
# This helps in situations where a remote homeserver invites a local user to
# a room that we're already participating in; and we've persisted the invite
# as an out-of-band membership (outlier), but it hasn't been pushed to us as
# part of a `/send` transaction yet and de-outliered. This also helps for
# any of the other out-of-band membership transitions.
#
# As an optimization, we could check if the room state already includes a
# non-`leave` membership event, then we can assume the membership event has
# been de-outliered and we don't need to check for an out-of-band
# membership. But we don't have the necessary information from a
# `StateMap[str]` and we'll just have to take the hit of this extra lookup
# for any membership event for now.
if self.type == EventTypes.Member and self.is_mine_id(self.state_key):
(
_membership,
member_event_id,
) = await self._store.get_local_current_membership_for_user_in_room(
user_id=self.state_key,
room_id=self.room_id,
)
# There is no need to check if the membership is actually an
# out-of-band membership (`outlier`) as we would end up with the
# same result either way (adding the member event to the
# `auth_event_ids`).
if (
member_event_id is not None
# We only need to be careful about duplicating the event in the
# `auth_event_ids` list (duplicate `type`/`state_key` is part of the
# authorization rules)
and member_event_id not in auth_event_ids
):
auth_event_ids.append(member_event_id)
# Also make sure to point to the previous membership event that will
# allow this one to happen so the computed state works out.
prev_event_ids.append(member_event_id)

format_version = self.room_version.event_format
# The types of auth/prev events changes between event versions.
prev_events: Union[StrCollection, List[Tuple[str, Dict[str, str]]]]
Expand Down
1 change: 0 additions & 1 deletion synapse/federation/federation_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -1256,7 +1256,6 @@ async def _process_incoming_pdus_in_room_inner(
# has started processing).
while True:
async with lock:
logger.info("handling received PDU in room %s: %s", room_id, event)
MadLittleMods marked this conversation as resolved.
Show resolved Hide resolved
try:
with nested_logging_context(event.event_id):
# We're taking out a lock within a lock, which could
Expand Down
4 changes: 2 additions & 2 deletions synapse/handlers/federation_event.py
Original file line number Diff line number Diff line change
Expand Up @@ -2272,8 +2272,8 @@ async def persist_events_and_notify(
event_and_contexts, backfilled=backfilled
)

# After persistence we always need to notify replication there may
# be new data.
# After persistence, we always need to notify replication there may be new
# data (backfilled or not) because TODO.
self._notifier.notify_replication()
Comment on lines +2275 to 2277
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For non-backfilled events, we already call _notify_persisted_event (just below) -> on_new_room_events -> notify_new_room_events -> notify_replication

Essentially, I want to fill in the context here: We never notify clients about backfilled events but it's important to let all the workers know about any new event (backfilled or not) because TODO

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"...they may need to act on that event type"?

One example is facilitating the Synapse Module API; where a module could be loaded on to any worker. A module may want to act on certain types of backfilled events arriving.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One example is facilitating the Synapse Module API; where a module could be loaded on to any worker. A module may want to act on certain types of backfilled events arriving.

@anoadragon453 Are you sure about this? I'm pretty sure the Synapse module on_new_event hook doesn't get called for backfilled events. At-least that's my assumption in the Synapse module I've been working on and I even have asserts for it that don't get triggered (which are stressed by some Complement tests doing federation things).

But now I'm no longer confident in that assumption.


if self._ephemeral_messages_enabled:
Expand Down
161 changes: 161 additions & 0 deletions tests/federation/test_federation_devices.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
#
# This file is licensed under the Affero General Public License (AGPL) version 3.
#
# Copyright (C) 2024 New Vector, Ltd
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as
# published by the Free Software Foundation, either version 3 of the
# License, or (at your option) any later version.
#
# See the GNU Affero General Public License for more details:
# <https://www.gnu.org/licenses/agpl-3.0.html>.
#
# Originally licensed under the Apache License, Version 2.0:
# <http://www.apache.org/licenses/LICENSE-2.0>.
#
# [This file includes modifications made by New Vector Limited]
#
#

import logging
from unittest.mock import AsyncMock, Mock

from twisted.test.proto_helpers import MemoryReactor

from synapse.handlers.device import DeviceListUpdater
from synapse.server import HomeServer
from synapse.types import JsonDict
from synapse.util import Clock
from synapse.util.retryutils import NotRetryingDestination

from tests import unittest

logger = logging.getLogger(__name__)


class DeviceListResyncTestCase(unittest.HomeserverTestCase):
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've moved these tests from tests/test_federation.py

def prepare(self, reactor: MemoryReactor, clock: Clock, hs: HomeServer) -> None:
self.store = self.hs.get_datastores().main

def test_retry_device_list_resync(self) -> None:
"""Tests that device lists are marked as stale if they couldn't be synced, and
that stale device lists are retried periodically.
"""
remote_user_id = "@john:test_remote"
remote_origin = "test_remote"

# Track the number of attempts to resync the user's device list.
self.resync_attempts = 0

# When this function is called, increment the number of resync attempts (only if
# we're querying devices for the right user ID), then raise a
# NotRetryingDestination error to fail the resync gracefully.
def query_user_devices(
destination: str, user_id: str, timeout: int = 30000
) -> JsonDict:
if user_id == remote_user_id:
self.resync_attempts += 1

raise NotRetryingDestination(0, 0, destination)

# Register the mock on the federation client.
federation_client = self.hs.get_federation_client()
federation_client.query_user_devices = Mock(side_effect=query_user_devices) # type: ignore[method-assign]

# Register a mock on the store so that the incoming update doesn't fail because
# we don't share a room with the user.
self.store.get_rooms_for_user = AsyncMock(return_value=["!someroom:test"])

# Manually inject a fake device list update. We need this update to include at
# least one prev_id so that the user's device list will need to be retried.
device_list_updater = self.hs.get_device_handler().device_list_updater
assert isinstance(device_list_updater, DeviceListUpdater)
self.get_success(
device_list_updater.incoming_device_list_update(
origin=remote_origin,
edu_content={
"deleted": False,
"device_display_name": "Mobile",
"device_id": "QBUAZIFURK",
"prev_id": [5],
"stream_id": 6,
"user_id": remote_user_id,
},
)
)

# Check that there was one resync attempt.
self.assertEqual(self.resync_attempts, 1)

# Check that the resync attempt failed and caused the user's device list to be
# marked as stale.
need_resync = self.get_success(
self.store.get_user_ids_requiring_device_list_resync()
)
self.assertIn(remote_user_id, need_resync)

# Check that waiting for 30 seconds caused Synapse to retry resyncing the device
# list.
self.reactor.advance(30)
self.assertEqual(self.resync_attempts, 2)

def test_cross_signing_keys_retry(self) -> None:
"""Tests that resyncing a device list correctly processes cross-signing keys from
the remote server.
"""
remote_user_id = "@john:test_remote"
remote_master_key = "85T7JXPFBAySB/jwby4S3lBPTqY3+Zg53nYuGmu1ggY"
remote_self_signing_key = "QeIiFEjluPBtI7WQdG365QKZcFs9kqmHir6RBD0//nQ"

# Register mock device list retrieval on the federation client.
federation_client = self.hs.get_federation_client()
federation_client.query_user_devices = AsyncMock( # type: ignore[method-assign]
return_value={
"user_id": remote_user_id,
"stream_id": 1,
"devices": [],
"master_key": {
"user_id": remote_user_id,
"usage": ["master"],
"keys": {"ed25519:" + remote_master_key: remote_master_key},
},
"self_signing_key": {
"user_id": remote_user_id,
"usage": ["self_signing"],
"keys": {
"ed25519:" + remote_self_signing_key: remote_self_signing_key
},
},
}
)

# Resync the device list.
device_handler = self.hs.get_device_handler()
self.get_success(
device_handler.device_list_updater.multi_user_device_resync(
[remote_user_id]
),
)

# Retrieve the cross-signing keys for this user.
keys = self.get_success(
self.store.get_e2e_cross_signing_keys_bulk(user_ids=[remote_user_id]),
)
self.assertIn(remote_user_id, keys)
key = keys[remote_user_id]
assert key is not None

# Check that the master key is the one returned by the mock.
master_key = key["master"]
self.assertEqual(len(master_key["keys"]), 1)
self.assertTrue("ed25519:" + remote_master_key in master_key["keys"].keys())
self.assertTrue(remote_master_key in master_key["keys"].values())

# Check that the self-signing key is the one returned by the mock.
self_signing_key = key["self_signing"]
self.assertEqual(len(self_signing_key["keys"]), 1)
self.assertTrue(
"ed25519:" + remote_self_signing_key in self_signing_key["keys"].keys(),
)
self.assertTrue(remote_self_signing_key in self_signing_key["keys"].values())
Loading
Loading