Skip to content

Commit 8fb9c10

Browse files
authored
Add support for MSC4293 - Redact on Kick/Ban (#18540)
1 parent a82b8a9 commit 8fb9c10

7 files changed

Lines changed: 1194 additions & 14 deletions

File tree

changelog.d/18540.feature

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Add support for [MSC4293](https://github.com/matrix-org/matrix-spec-proposals/pull/4293) - Redact on Kick/Ban.

synapse/config/experimental.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -582,6 +582,9 @@ def read_config(
582582
# MSC4155: Invite filtering
583583
self.msc4155_enabled: bool = experimental.get("msc4155_enabled", False)
584584

585+
# MSC4293: Redact on Kick/Ban
586+
self.msc4293_enabled: bool = experimental.get("msc4293_enabled", False)
587+
585588
# MSC4306: Thread Subscriptions
586589
# (and MSC4308: sliding sync extension for thread subscriptions)
587590
self.msc4306_enabled: bool = experimental.get("msc4306_enabled", False)

synapse/rest/client/room.py

Lines changed: 20 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1100,6 +1100,7 @@ def __init__(self, hs: "HomeServer"):
11001100
super().__init__(hs)
11011101
self.room_member_handler = hs.get_room_member_handler()
11021102
self.auth = hs.get_auth()
1103+
self.config = hs.config
11031104

11041105
def register(self, http_server: HttpServer) -> None:
11051106
# /rooms/$roomid/[join|invite|leave|ban|unban|kick]
@@ -1123,12 +1124,12 @@ async def _do(
11231124
}:
11241125
raise AuthError(403, "Guest access not allowed")
11251126

1126-
content = parse_json_object_from_request(request, allow_empty_body=True)
1127+
request_body = parse_json_object_from_request(request, allow_empty_body=True)
11271128

11281129
if membership_action == "invite" and all(
1129-
key in content for key in ("medium", "address")
1130+
key in request_body for key in ("medium", "address")
11301131
):
1131-
if not all(key in content for key in ("id_server", "id_access_token")):
1132+
if not all(key in request_body for key in ("id_server", "id_access_token")):
11321133
raise SynapseError(
11331134
HTTPStatus.BAD_REQUEST,
11341135
"`id_server` and `id_access_token` are required when doing 3pid invite",
@@ -1139,12 +1140,12 @@ async def _do(
11391140
await self.room_member_handler.do_3pid_invite(
11401141
room_id,
11411142
requester.user,
1142-
content["medium"],
1143-
content["address"],
1144-
content["id_server"],
1143+
request_body["medium"],
1144+
request_body["address"],
1145+
request_body["id_server"],
11451146
requester,
11461147
txn_id,
1147-
content["id_access_token"],
1148+
request_body["id_access_token"],
11481149
)
11491150
except ShadowBanError:
11501151
# Pretend the request succeeded.
@@ -1153,12 +1154,19 @@ async def _do(
11531154

11541155
target = requester.user
11551156
if membership_action in ["invite", "ban", "unban", "kick"]:
1156-
assert_params_in_dict(content, ["user_id"])
1157-
target = UserID.from_string(content["user_id"])
1157+
assert_params_in_dict(request_body, ["user_id"])
1158+
target = UserID.from_string(request_body["user_id"])
11581159

11591160
event_content = None
1160-
if "reason" in content:
1161-
event_content = {"reason": content["reason"]}
1161+
if "reason" in request_body:
1162+
event_content = {"reason": request_body["reason"]}
1163+
if self.config.experimental.msc4293_enabled:
1164+
if "org.matrix.msc4293.redact_events" in request_body:
1165+
if event_content is None:
1166+
event_content = {}
1167+
event_content["org.matrix.msc4293.redact_events"] = request_body[
1168+
"org.matrix.msc4293.redact_events"
1169+
]
11621170

11631171
try:
11641172
await self.room_member_handler.update_membership(
@@ -1167,7 +1175,7 @@ async def _do(
11671175
room_id=room_id,
11681176
action=membership_action,
11691177
txn_id=txn_id,
1170-
third_party_signed=content.get("third_party_signed", None),
1178+
third_party_signed=request_body.get("third_party_signed", None),
11711179
content=event_content,
11721180
)
11731181
except ShadowBanError:

synapse/storage/databases/main/events.py

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -377,11 +377,130 @@ async def _persist_events_and_state_updates(
377377

378378
event_counter.labels(event.type, origin_type, origin_entity).inc()
379379

380+
if (
381+
not self.hs.config.experimental.msc4293_enabled
382+
or event.type != EventTypes.Member
383+
or event.state_key is None
384+
):
385+
continue
386+
387+
# check if this is an unban/join that will undo a ban/kick redaction for
388+
# a user in the room
389+
if event.membership in [Membership.LEAVE, Membership.JOIN]:
390+
if (
391+
event.membership == Membership.LEAVE
392+
and event.sender == event.state_key
393+
):
394+
# self-leave, ignore
395+
continue
396+
397+
# if there is an existing ban/leave causing redactions for
398+
# this user/room combination update the entry with the stream
399+
# ordering when the redactions should stop - in the case of a backfilled
400+
# event where the stream ordering is negative, use the current max stream
401+
# ordering
402+
stream_ordering = event.internal_metadata.stream_ordering
403+
assert stream_ordering is not None
404+
if stream_ordering < 0:
405+
stream_ordering = self._stream_id_gen.get_current_token()
406+
await self.db_pool.simple_update(
407+
"room_ban_redactions",
408+
{"room_id": event.room_id, "user_id": event.state_key},
409+
{"redact_end_ordering": stream_ordering},
410+
desc="room_ban_redactions update redact_end_ordering",
411+
)
412+
413+
# check for msc4293 redact_events flag and apply if found
414+
if event.membership not in [Membership.LEAVE, Membership.BAN]:
415+
continue
416+
redact = event.content.get("org.matrix.msc4293.redact_events", False)
417+
if not redact or not isinstance(redact, bool):
418+
continue
419+
# self-bans currently are not authorized so we don't check for that
420+
# case
421+
if (
422+
event.membership == Membership.BAN
423+
and event.sender == event.state_key
424+
):
425+
continue
426+
427+
# check that sender can redact
428+
redact_allowed = await self._can_sender_redact(event)
429+
430+
# Signal that this user's past events in this room
431+
# should be redacted by adding an entry to
432+
# `room_ban_redactions`.
433+
if redact_allowed:
434+
await self.db_pool.simple_upsert(
435+
"room_ban_redactions",
436+
{"room_id": event.room_id, "user_id": event.state_key},
437+
{
438+
"redacting_event_id": event.event_id,
439+
"redact_end_ordering": None,
440+
},
441+
{
442+
"room_id": event.room_id,
443+
"user_id": event.state_key,
444+
"redacting_event_id": event.event_id,
445+
"redact_end_ordering": None,
446+
},
447+
)
448+
449+
# normally the cache entry for a redacted event would be invalidated
450+
# by an arriving redaction event, but since we are not creating redaction
451+
# events we invalidate manually
452+
self.store._invalidate_local_get_event_cache_room_id(event.room_id)
453+
454+
self.store._invalidate_async_get_event_cache_room_id(event.room_id)
455+
380456
if new_forward_extremities:
381457
self.store.get_latest_event_ids_in_room.prefill(
382458
(room_id,), frozenset(new_forward_extremities)
383459
)
384460

461+
async def _can_sender_redact(self, event: EventBase) -> bool:
462+
state_filter = StateFilter.from_types(
463+
[(EventTypes.PowerLevels, ""), (EventTypes.Create, "")]
464+
)
465+
state = await self.store.get_partial_filtered_current_state_ids(
466+
event.room_id, state_filter
467+
)
468+
pl_id = state[(EventTypes.PowerLevels, "")]
469+
pl_event = await self.store.get_event(pl_id, allow_none=True)
470+
471+
if pl_event is None:
472+
# per the spec, if a power level event isn't in the room, grant the creator
473+
# level 100 and all other users 0
474+
create_id = state[(EventTypes.Create, "")]
475+
create_event = await self.store.get_event(create_id, allow_none=True)
476+
if create_event is None:
477+
# not sure how this would happen but if it does then just deny the redaction
478+
logger.warning("No create event found for room %s", event.room_id)
479+
return False
480+
if create_event.sender == event.sender:
481+
return True
482+
483+
assert pl_event is not None
484+
sender_level = pl_event.content.get("users", {}).get(event.sender)
485+
if sender_level is None:
486+
sender_level = pl_event.content.get("users_default", 0)
487+
488+
redact_level = pl_event.content.get("redact")
489+
if redact_level is None:
490+
redact_level = pl_event.content.get("events_default", 0)
491+
492+
room_redaction_level = pl_event.content.get("events", {}).get(
493+
"m.room.redaction"
494+
)
495+
if room_redaction_level is not None:
496+
if sender_level < room_redaction_level:
497+
return False
498+
499+
if sender_level >= redact_level:
500+
return True
501+
502+
return False
503+
385504
async def _calculate_sliding_sync_table_changes(
386505
self,
387506
room_id: str,

synapse/storage/databases/main/events_worker.py

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
# [This file includes modifications made by New Vector Limited]
1818
#
1919
#
20-
20+
import json
2121
import logging
2222
import threading
2323
import weakref
@@ -976,6 +976,13 @@ def _invalidate_local_get_event_cache_room_id(self, room_id: str) -> None:
976976
self._event_ref.clear()
977977
self._current_event_fetches.clear()
978978

979+
def _invalidate_async_get_event_cache_room_id(self, room_id: str) -> None:
980+
"""
981+
Clears the async get_event cache for a room. Currently a no-op until
982+
an async get_event cache is implemented - see https://github.com/matrix-org/synapse/pull/13242
983+
for preliminary work.
984+
"""
985+
979986
async def _get_events_from_cache(
980987
self, events: Iterable[str], update_metrics: bool = True
981988
) -> Dict[str, EventCacheEntry]:
@@ -1575,6 +1582,44 @@ def _fetch_event_rows(
15751582
if d:
15761583
d.redactions.append(redacter)
15771584

1585+
# check for MSC4932 redactions
1586+
to_check = []
1587+
events: List[_EventRow] = []
1588+
for e in evs:
1589+
event = event_dict.get(e)
1590+
if not event:
1591+
continue
1592+
events.append(event)
1593+
event_json = json.loads(event.json)
1594+
room_id = event_json.get("room_id")
1595+
user_id = event_json.get("sender")
1596+
to_check.append((room_id, user_id))
1597+
1598+
# likely that some of these events may be for the same room/user combo, in
1599+
# which case we don't need to do redundant queries
1600+
to_check_set = set(to_check)
1601+
for room_and_user in to_check_set:
1602+
room_redactions_sql = "SELECT redacting_event_id, redact_end_ordering FROM room_ban_redactions WHERE room_id = ? and user_id = ?"
1603+
txn.execute(room_redactions_sql, room_and_user)
1604+
1605+
res = txn.fetchone()
1606+
# we have a redaction for a room, user_id combo - apply it to matching events
1607+
if not res:
1608+
continue
1609+
for e_row in events:
1610+
e_json = json.loads(e_row.json)
1611+
room_id = e_json.get("room_id")
1612+
user_id = e_json.get("sender")
1613+
if room_and_user != (room_id, user_id):
1614+
continue
1615+
redacting_event_id, redact_end_ordering = res
1616+
if redact_end_ordering:
1617+
# Avoid redacting any events arriving *after* the membership event which
1618+
# ends an active redaction - note that this will always redact
1619+
# backfilled events, as they have a negative stream ordering
1620+
if e_row.stream_ordering >= redact_end_ordering:
1621+
continue
1622+
e_row.redactions.append(redacting_event_id)
15781623
return event_dict
15791624

15801625
def _maybe_redact_event_row(
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
--
2+
-- This file is licensed under the Affero General Public License (AGPL) version 3.
3+
--
4+
-- Copyright (C) 2025 New Vector, Ltd
5+
--
6+
-- This program is free software: you can redistribute it and/or modify
7+
-- it under the terms of the GNU Affero General Public License as
8+
-- published by the Free Software Foundation, either version 3 of the
9+
-- License, or (at your option) any later version.
10+
--
11+
-- See the GNU Affero General Public License for more details:
12+
-- <https://www.gnu.org/licenses/agpl-3.0.html>.
13+
14+
CREATE TABLE room_ban_redactions(
15+
room_id text NOT NULL,
16+
user_id text NOT NULL,
17+
redacting_event_id text NOT NULL,
18+
redact_end_ordering bigint DEFAULT NULL, -- stream ordering after which redactions are not applied
19+
CONSTRAINT room_ban_redaction_uniqueness UNIQUE (room_id, user_id)
20+
);
21+

0 commit comments

Comments
 (0)