mirror of
https://github.com/element-hq/synapse.git
synced 2026-11-04 11:17:51 +00:00
Auto-join a user whose knock is accepted (MSC4509)
When an admin accepts a knock, the knocker gets an invite and has to accept it by hand, though they already asked to join. With `experimental_features.msc4509_enabled` set, this server joins them itself. The flag defaults to off while MSC4509 is unstable. An invite accepts a knock when the invitee's own knock event is among the invite's auth events, as the MSC4509 "Auto-joining on accepted knocks" section specifies. The auth events show the invite was authorised against the knock, so an unrelated invite does not qualify. They also work for an invite received over federation, where this server holds the knock it created but no state for the room. The check runs from the two places that already hold the invite and its auth events: `RoomMemberHandler._local_membership_update` for a local invite and `FederationHandler.on_invite_request` for a remote one. A per-event module callback such as `on_new_event` would fetch the room's full current state for every persisted event, before any event-type filter. The join runs as a background process. The caller holds the per-room membership lock the join needs, and for a remote invite the `send_join` handshake must not block the inviting server's `/invite` request. A failed join is logged; the invite stays, so the user can still accept it by hand. MSC4509 requires the join to name the user who accepted the knock. Synapse already fills `unsigned.prev_sender` from the replaced invite, and the local test asserts it. The MSC4268 key bundle hint is left to a follow-up: sending to-device messages from an arbitrary worker needs a replication route to the `to_device` writer that does not exist yet. Co-authored-by: Matthew Hodgson <matthew@matrix.org> Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
5 files changed
+304
No files matched your search
@@ -0,0 +1 @@
|
||||
Add `experimental_features.msc4509_enabled`, which joins a user to a room as soon as their knock is accepted instead of leaving them an invite to accept by hand. Implements [MSC4509](https://github.com/matrix-org/matrix-spec-proposals/pull/4509).
|
||||
@@ -319,3 +319,8 @@ class ExperimentalConfig(Config):
|
||||
|
||||
# MSC4512: Delegating parts of the C-S and S-S API to application services
|
||||
self.msc4512_enabled: bool = experimental.get("msc4512_enabled", False)
|
||||
|
||||
# MSC4509: Auto-joining rooms when a knock is accepted
|
||||
# https://github.com/matrix-org/matrix-spec-proposals/pull/4509
|
||||
# Tracked in: https://github.com/element-hq/synapse/issues/16307
|
||||
self.msc4509_enabled: bool = experimental.get("msc4509_enabled", False)
|
||||
@@ -1643,6 +1643,8 @@ class FederationHandler:
|
||||
await self.store.remove_push_actions_from_staging(event.event_id)
|
||||
raise
|
||||
|
||||
await member_handler.maybe_auto_join_accepted_knock(event)
|
||||
|
||||
return event
|
||||
|
||||
async def do_remotely_reject_invite(
|
||||
|
||||
@@ -130,6 +130,7 @@ class RoomMemberHandler(metaclass=abc.ABCMeta):
|
||||
hs.get_module_api_callbacks().third_party_event_rules
|
||||
)
|
||||
self._server_notices_mxid = self.config.servernotices.server_notices_mxid
|
||||
self._msc4509_enabled = hs.config.experimental.msc4509_enabled
|
||||
self._enable_lookup = hs.config.registration.enable_3pid_lookup
|
||||
self.allow_per_room_profiles = self.config.server.allow_per_room_profiles
|
||||
|
||||
@@ -534,10 +535,75 @@ class RoomMemberHandler(metaclass=abc.ABCMeta):
|
||||
if i == max_retries - 1:
|
||||
raise e
|
||||
|
||||
if membership == Membership.INVITE:
|
||||
await self.maybe_auto_join_accepted_knock(result_event)
|
||||
|
||||
# we know it was persisted, so should have a stream ordering
|
||||
assert result_event.internal_metadata.stream_ordering
|
||||
return result_event.event_id, result_event.internal_metadata.stream_ordering
|
||||
|
||||
async def maybe_auto_join_accepted_knock(self, invite_event: EventBase) -> None:
|
||||
"""Join a local user to a room if the given invite accepts a knock of theirs.
|
||||
|
||||
An invite accepts a knock when the invitee's own knock event is among the
|
||||
invite's auth events, which is how MSC4509 specifies the check: the auth
|
||||
events show the invite was authorised against the knock, so an invite sent
|
||||
independently of it does not qualify. They are also the only evidence
|
||||
available for an invite received over federation, where this server holds no
|
||||
state for the room.
|
||||
|
||||
The join runs in a background process: it takes the same per-room membership
|
||||
lock as the caller, and for a remote invite it runs a `send_join` handshake
|
||||
which must not hold up the inviting server's `/invite` request.
|
||||
"""
|
||||
if not self._msc4509_enabled:
|
||||
return
|
||||
|
||||
assert invite_event.state_key is not None
|
||||
invitee = invite_event.state_key
|
||||
if not self.hs.is_mine_id(invitee):
|
||||
return
|
||||
|
||||
auth_events = await self.store.get_events(invite_event.auth_event_ids())
|
||||
accepts_knock = any(
|
||||
auth_event.type == EventTypes.Member
|
||||
and auth_event.state_key == invitee
|
||||
and auth_event.membership == Membership.KNOCK
|
||||
for auth_event in auth_events.values()
|
||||
)
|
||||
if not accepts_knock:
|
||||
return
|
||||
|
||||
self.hs.run_as_background_process(
|
||||
"msc4509_auto_join_on_accepted_knock",
|
||||
self._auto_join_accepted_knock,
|
||||
invitee,
|
||||
invite_event.room_id,
|
||||
)
|
||||
|
||||
async def _auto_join_accepted_knock(self, invitee: str, room_id: str) -> None:
|
||||
"""Join `invitee` to `room_id` on their behalf, after their knock was accepted.
|
||||
|
||||
If this fails the invite is still there, so the user can accept it by hand.
|
||||
"""
|
||||
try:
|
||||
await self.update_membership(
|
||||
requester=create_requester(invitee),
|
||||
target=UserID.from_string(invitee),
|
||||
room_id=room_id,
|
||||
action=Membership.JOIN,
|
||||
# The user did not ask for this join, and the invite which triggered
|
||||
# it was rate limited on the inviter.
|
||||
ratelimit=False,
|
||||
)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"Failed to join %s to %s after their knock was accepted",
|
||||
invitee,
|
||||
room_id,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
async def copy_room_tags_and_direct_to_room(
|
||||
self, old_room_id: str, new_room_id: str, user_id: str
|
||||
) -> None:
|
||||
|
||||
@@ -3,11 +3,13 @@ from unittest.mock import AsyncMock, patch
|
||||
from twisted.internet.testing import MemoryReactor
|
||||
|
||||
import synapse.rest.admin
|
||||
import synapse.rest.client.knock
|
||||
import synapse.rest.client.login
|
||||
import synapse.rest.client.room
|
||||
from synapse.api.constants import AccountDataTypes, EventTypes, Membership
|
||||
from synapse.api.errors import Codes, LimitExceededError, SynapseError
|
||||
from synapse.crypto.event_signing import add_hashes_and_signatures
|
||||
from synapse.events.snapshot import EventContext
|
||||
from synapse.federation.federation_client import SendJoinResult
|
||||
from synapse.server import HomeServer
|
||||
from synapse.types import JsonDict, UserID, create_requester
|
||||
@@ -724,3 +726,231 @@ class TestMSC4380InviteBlocking(FederatingHomeserverTestCase):
|
||||
).value
|
||||
self.assertEqual(f.code, 403)
|
||||
self.assertEqual(f.errcode, "M_INVITE_BLOCKED")
|
||||
|
||||
|
||||
class AutoJoinOnAcceptedKnockTestCase(FederatingHomeserverTestCase):
|
||||
"""MSC4509: join a user to a room as soon as their knock is accepted."""
|
||||
|
||||
servlets = [
|
||||
synapse.rest.admin.register_servlets,
|
||||
synapse.rest.client.knock.register_servlets,
|
||||
synapse.rest.client.login.register_servlets,
|
||||
synapse.rest.client.room.register_servlets,
|
||||
]
|
||||
|
||||
def prepare(self, reactor: MemoryReactor, clock: Clock, hs: HomeServer) -> None:
|
||||
self.store = hs.get_datastores().main
|
||||
self.fed_handler = hs.get_federation_handler()
|
||||
|
||||
self.creator = self.register_user("creator", "pass")
|
||||
self.creator_tok = self.login("creator", "pass")
|
||||
self.knocker = self.register_user("knocker", "pass")
|
||||
self.knocker_tok = self.login("knocker", "pass")
|
||||
|
||||
def _create_knockable_room(self) -> str:
|
||||
room_id = self.helper.create_room_as(
|
||||
self.creator, is_public=False, tok=self.creator_tok
|
||||
)
|
||||
self.helper.send_state(
|
||||
room_id,
|
||||
EventTypes.JoinRules,
|
||||
{"join_rule": "knock"},
|
||||
tok=self.creator_tok,
|
||||
)
|
||||
return room_id
|
||||
|
||||
def _current_membership(
|
||||
self, room_id: str, user_id: str
|
||||
) -> tuple[str | None, str | None]:
|
||||
membership, event_id = self.get_success(
|
||||
self.store.get_local_current_membership_for_user_in_room(user_id, room_id)
|
||||
)
|
||||
return membership, event_id
|
||||
|
||||
@override_config({"experimental_features": {"msc4509_enabled": True}})
|
||||
def test_local_invite_accepting_knock(self) -> None:
|
||||
"""An invite authorised against the invitee's knock joins them."""
|
||||
room_id = self._create_knockable_room()
|
||||
self.helper.knock(room_id, self.knocker, tok=self.knocker_tok)
|
||||
|
||||
self.helper.invite(room_id, self.creator, self.knocker, tok=self.creator_tok)
|
||||
self.pump()
|
||||
|
||||
membership, event_id = self._current_membership(room_id, self.knocker)
|
||||
self.assertEqual(membership, Membership.JOIN)
|
||||
|
||||
# The knocker's clients may never see the invite, so the join has to name
|
||||
# the user who accepted the knock.
|
||||
channel = self.make_request(
|
||||
"GET",
|
||||
f"/rooms/{room_id}/messages?dir=b&limit=1",
|
||||
access_token=self.knocker_tok,
|
||||
)
|
||||
self.assertEqual(channel.code, 200, channel.json_body)
|
||||
join_event = channel.json_body["chunk"][0]
|
||||
self.assertEqual(join_event["event_id"], event_id)
|
||||
self.assertEqual(join_event["unsigned"]["prev_sender"], self.creator)
|
||||
self.assertEqual(
|
||||
join_event["unsigned"]["prev_content"]["membership"], Membership.INVITE
|
||||
)
|
||||
|
||||
def test_local_invite_accepting_knock_with_msc4509_disabled(self) -> None:
|
||||
"""Without the flag the accepted knock stays an ordinary invite."""
|
||||
room_id = self._create_knockable_room()
|
||||
self.helper.knock(room_id, self.knocker, tok=self.knocker_tok)
|
||||
|
||||
self.helper.invite(room_id, self.creator, self.knocker, tok=self.creator_tok)
|
||||
self.pump()
|
||||
|
||||
membership, _ = self._current_membership(room_id, self.knocker)
|
||||
self.assertEqual(membership, Membership.INVITE)
|
||||
|
||||
@override_config({"experimental_features": {"msc4509_enabled": True}})
|
||||
def test_plain_invite(self) -> None:
|
||||
"""An invite which does not accept a knock is left alone."""
|
||||
room_id = self.helper.create_room_as(
|
||||
self.creator, is_public=False, tok=self.creator_tok
|
||||
)
|
||||
|
||||
self.helper.invite(room_id, self.creator, self.knocker, tok=self.creator_tok)
|
||||
self.pump()
|
||||
|
||||
membership, _ = self._current_membership(room_id, self.knocker)
|
||||
self.assertEqual(membership, Membership.INVITE)
|
||||
|
||||
@override_config({"experimental_features": {"msc4509_enabled": True}})
|
||||
def test_remote_invite_accepting_knock(self) -> None:
|
||||
"""An invite received over federation which accepts a knock we hold as
|
||||
an outlier starts a remote join."""
|
||||
room_version = self.hs.config.server.default_room_version
|
||||
remote_user = f"@creator:{self.OTHER_SERVER_NAME}"
|
||||
|
||||
create_event_source: JsonDict = {
|
||||
"type": EventTypes.Create,
|
||||
"content": {"room_version": room_version.identifier},
|
||||
"sender": remote_user,
|
||||
"state_key": "",
|
||||
"depth": 1,
|
||||
"origin_server_ts": 0,
|
||||
"auth_events": [],
|
||||
"prev_events": [],
|
||||
}
|
||||
self.add_hashes_and_signatures_from_other_server(
|
||||
create_event_source, room_version
|
||||
)
|
||||
create_event = make_test_pdu_event(create_event_source, room_version)
|
||||
room_id = create_event.room_id
|
||||
|
||||
# Our user knocked on a room we are not in: all we hold is the knock.
|
||||
knock_event = make_test_pdu_event(
|
||||
{
|
||||
"type": EventTypes.Member,
|
||||
"content": {"membership": Membership.KNOCK},
|
||||
"room_id": room_id,
|
||||
"sender": self.knocker,
|
||||
"state_key": self.knocker,
|
||||
"depth": 10,
|
||||
"origin_server_ts": self.clock.time_msec(),
|
||||
},
|
||||
room_version,
|
||||
)
|
||||
knock_event.internal_metadata.outlier = True
|
||||
knock_event.internal_metadata.out_of_band_membership = True
|
||||
self.get_success(
|
||||
self.store.maybe_store_room_on_outlier_membership(
|
||||
room_id=room_id, room_version=room_version
|
||||
)
|
||||
)
|
||||
self.get_success(
|
||||
self.hs.get_federation_event_handler().persist_events_and_notify(
|
||||
room_id,
|
||||
[
|
||||
(
|
||||
knock_event,
|
||||
EventContext.for_outlier(self.hs.get_storage_controllers()),
|
||||
)
|
||||
],
|
||||
)
|
||||
)
|
||||
|
||||
invite_event = make_test_pdu_event(
|
||||
{
|
||||
"type": EventTypes.Member,
|
||||
"content": {"membership": Membership.INVITE},
|
||||
"room_id": room_id,
|
||||
"sender": remote_user,
|
||||
"state_key": self.knocker,
|
||||
"depth": 11,
|
||||
"auth_events": [knock_event.event_id],
|
||||
"origin_server_ts": self.clock.time_msec(),
|
||||
},
|
||||
room_version,
|
||||
)
|
||||
|
||||
# The remote's response to our `make_join`/`send_join`.
|
||||
join_event_source = {
|
||||
"type": EventTypes.Member,
|
||||
"content": {"membership": Membership.JOIN},
|
||||
"room_id": room_id,
|
||||
"sender": self.knocker,
|
||||
"state_key": self.knocker,
|
||||
"depth": 12,
|
||||
"origin_server_ts": self.clock.time_msec(),
|
||||
"auth_events": [],
|
||||
"prev_events": [create_event.event_id],
|
||||
}
|
||||
add_hashes_and_signatures(
|
||||
room_version, join_event_source, self.hs.hostname, self.hs.signing_key
|
||||
)
|
||||
join_event = make_test_pdu_event(join_event_source, room_version)
|
||||
mock_make_membership_event = AsyncMock(
|
||||
return_value=(self.OTHER_SERVER_NAME, join_event, room_version)
|
||||
)
|
||||
mock_send_join = AsyncMock(
|
||||
return_value=SendJoinResult(
|
||||
join_event,
|
||||
self.OTHER_SERVER_NAME,
|
||||
state=[create_event],
|
||||
auth_chain=[create_event],
|
||||
partial_state=False,
|
||||
servers_in_room=frozenset(),
|
||||
state_dag=None,
|
||||
)
|
||||
)
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
self.fed_handler.federation_client,
|
||||
"make_membership_event",
|
||||
mock_make_membership_event,
|
||||
),
|
||||
patch.object(
|
||||
self.fed_handler.federation_client, "send_join", mock_send_join
|
||||
),
|
||||
patch(
|
||||
"synapse.event_auth._is_membership_change_allowed", return_value=None
|
||||
),
|
||||
patch(
|
||||
"synapse.handlers.federation_event.check_state_dependent_auth_rules",
|
||||
return_value=None,
|
||||
),
|
||||
):
|
||||
self.get_success(
|
||||
self.fed_handler.on_invite_request(
|
||||
origin=self.OTHER_SERVER_NAME,
|
||||
event=invite_event,
|
||||
room_version=room_version,
|
||||
)
|
||||
)
|
||||
self.pump(1)
|
||||
|
||||
mock_make_membership_event.assert_called_once()
|
||||
destinations, made_room_id, made_user_id, made_membership = (
|
||||
mock_make_membership_event.call_args.args[:4]
|
||||
)
|
||||
self.assertEqual(list(destinations), [self.OTHER_SERVER_NAME])
|
||||
self.assertEqual(made_room_id, room_id)
|
||||
self.assertEqual(made_user_id, self.knocker)
|
||||
self.assertEqual(made_membership, Membership.JOIN)
|
||||
membership, _ = self._current_membership(room_id, self.knocker)
|
||||
self.assertEqual(membership, Membership.JOIN)
|
||||
Reference in new issue
Block a user