Skip to content
Merged
1 change: 1 addition & 0 deletions changelog.d/18585.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
When admins enable themselves to see soft-failed events, they will also see if the cause is due to the policy server flagging them as spam via `unsigned`.
42 changes: 42 additions & 0 deletions docs/admin_api/client_server_api_extensions.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,4 +22,46 @@ To receive soft failed events in APIs like `/sync` and `/messages`, set `return_
to `true` in the admin client config. When `false`, the normal behaviour of these endpoints is to
exclude soft failed events.

**Note**: If the policy server flagged the event as spam and that caused soft failure, that will be indicated
in the event's `unsigned` content like so:

```json
{
"type": "m.room.message",
"other": "event_fields_go_here",
"unsigned": {
"io.element.synapse.soft_failed": true,
"io.element.synapse.policy_server_spammy": true
}
}
```

Default: `false`

## See events marked spammy by policy servers

Learn more about policy servers from [MSC4284](https://github.com/matrix-org/matrix-spec-proposals/pull/4284).

Similar to `return_soft_failed_events`, clients logged in with admin accounts can see events which were
flagged by the policy server as spammy (and thus soft failed) by setting `return_policy_server_spammy_events`
to `true`.

`return_policy_server_spammy_events` may be `true` while `return_soft_failed_events` is `false` to only see
policy server-flagged events. When `return_soft_failed_events` is `true` however, `return_policy_server_spammy_events`
is always `true`.

Events which were flagged by the policy will be flagged as `io.element.synapse.policy_server_spammy` in the
event's `unsigned` content, like so:

```json
{
"type": "m.room.message",
"other": "event_fields_go_here",
"unsigned": {
"io.element.synapse.soft_failed": true,
"io.element.synapse.policy_server_spammy": true
}
}
```

Default: `true` if `return_soft_failed_events` is `true`, otherwise `false`
24 changes: 24 additions & 0 deletions rust/src/events/internal_metadata.rs
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ enum EventInternalMetadataData {
RecheckRedaction(bool),
SoftFailed(bool),
ProactivelySend(bool),
PolicyServerSpammy(bool),
Redacted(bool),
TxnId(Box<str>),
TokenId(i64),
Expand Down Expand Up @@ -96,6 +97,13 @@ impl EventInternalMetadataData {
.to_owned()
.into_any(),
),
EventInternalMetadataData::PolicyServerSpammy(o) => (
pyo3::intern!(py, "policy_server_spammy"),
o.into_pyobject(py)
.unwrap_infallible()
.to_owned()
.into_any(),
),
EventInternalMetadataData::Redacted(o) => (
pyo3::intern!(py, "redacted"),
o.into_pyobject(py)
Expand Down Expand Up @@ -155,6 +163,11 @@ impl EventInternalMetadataData {
.extract()
.with_context(|| format!("'{key_str}' has invalid type"))?,
),
"policy_server_spammy" => EventInternalMetadataData::PolicyServerSpammy(
value
.extract()
.with_context(|| format!("'{key_str}' has invalid type"))?,
),
"redacted" => EventInternalMetadataData::Redacted(
value
.extract()
Expand Down Expand Up @@ -427,6 +440,17 @@ impl EventInternalMetadata {
set_property!(self, ProactivelySend, obj);
}

#[getter]
fn get_policy_server_spammy(&self) -> PyResult<bool> {
Ok(get_property_opt!(self, PolicyServerSpammy)
.copied()
.unwrap_or(false))
}
#[setter]
fn set_policy_server_spammy(&mut self, obj: bool) {
set_property!(self, PolicyServerSpammy, obj);
}

#[getter]
fn get_redacted(&self) -> PyResult<bool> {
let bool = get_property!(self, Redacted)?;
Expand Down
7 changes: 5 additions & 2 deletions synapse/events/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -538,8 +538,11 @@ def serialize_event(
d["content"] = dict(d["content"])
d["content"]["redacts"] = e.redacts

if config.include_admin_metadata and e.internal_metadata.is_soft_failed():
d["unsigned"]["io.element.synapse.soft_failed"] = True
if config.include_admin_metadata:
if e.internal_metadata.is_soft_failed():
d["unsigned"]["io.element.synapse.soft_failed"] = True
if e.internal_metadata.policy_server_spammy:
d["unsigned"]["io.element.synapse.policy_server_spammy"] = True

only_event_fields = config.only_event_fields
if only_event_fields:
Expand Down
1 change: 1 addition & 0 deletions synapse/federation/federation_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,7 @@ async def _check_sigs_and_hash(
"Event not allowed by policy server, soft-failing %s", pdu.event_id
)
pdu.internal_metadata.soft_failed = True
pdu.internal_metadata.policy_server_spammy = True
# Note: we don't redact the event so admins can inspect the event after the
# fact. Other processes may redact the event, but that won't be applied to
# the database copy of the event until the server's config requires it.
Expand Down
3 changes: 3 additions & 0 deletions synapse/handlers/message.py
Original file line number Diff line number Diff line change
Expand Up @@ -1101,6 +1101,9 @@ async def _create_and_send_nonmember_event_locked(

policy_allowed = await self._policy_handler.is_event_allowed(event)
if not policy_allowed:
# We shouldn't need to set the metadata because the raise should
# cause the request to be denied, but just in case:
event.internal_metadata.policy_server_spammy = True
logger.warning(
"Event not allowed by policy server, rejecting %s",
event.event_id,
Expand Down
4 changes: 4 additions & 0 deletions synapse/storage/admin_client_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,12 @@ def __init__(self, account_data: Optional[JsonMapping]):
# `unsigned` portion of the event to inform clients that the event
# is soft-failed.
self.return_soft_failed_events: bool = False
self.return_policy_server_spammy_events: bool = False

if account_data:
self.return_soft_failed_events = account_data.get(
"return_soft_failed_events", False
)
self.return_policy_server_spammy_events = account_data.get(
"return_policy_server_spammy_events", self.return_soft_failed_events
)
3 changes: 3 additions & 0 deletions synapse/synapse_rust/events.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,9 @@ class EventInternalMetadata:
proactively_send: bool
redacted: bool

policy_server_spammy: bool
"""whether the policy server indicated that this event is spammy"""

txn_id: str
"""The transaction ID, if it was set when the event was created."""
token_id: int
Expand Down
26 changes: 21 additions & 5 deletions synapse/visibility.py
Original file line number Diff line number Diff line change
Expand Up @@ -117,13 +117,29 @@ async def filter_events_for_client(
# We copy the events list to guarantee any modifications we make will only
# happen within the function.
events_before_filtering = events.copy()
# Default case is to *exclude* soft-failed events
events = [e for e in events if not e.internal_metadata.is_soft_failed()]
client_config = await storage.main.get_admin_client_config_for_user(user_id)
if not (
filter_send_to_client
and client_config.return_soft_failed_events
and await storage.main.is_server_admin(UserID.from_string(user_id))
if filter_send_to_client and await storage.main.is_server_admin(
UserID.from_string(user_id)
):
events = [e for e in events if not e.internal_metadata.is_soft_failed()]
if client_config.return_soft_failed_events:
# The user has requested that all events be included, so do that.
# We copy the list for mutation safety.
events = events_before_filtering.copy()
Copy link
Member

Choose a reason for hiding this comment

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

There's a slight performance penalty to iterating over all events and then doing it again here, rather than moving the default case into the else blocks.

But since is_soft_failed() is very cheap, I think it's find to aid readability.

Copy link
Member Author

Choose a reason for hiding this comment

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

I'm also not personally concerned with a slight performance hit from asking for more stuff :p

Copy link
Member

Choose a reason for hiding this comment

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

I meant more that the:

[e for e in events if not e.internal_metadata.is_soft_failed()]

isn't necessary in those cases.

elif client_config.return_policy_server_spammy_events:
# Include events that were soft failed by a policy server (marked spammy),
# but exclude all other soft failed events. We also want to include all
# not-soft-failed events, per usual operation.
events = [
e
for e in events_before_filtering
if not e.internal_metadata.is_soft_failed()
or e.internal_metadata.policy_server_spammy
]
# else - no change in behaviour; use default case
# else - no change in behaviour; use default case

if len(events_before_filtering) != len(events):
if filtered_event_logger.isEnabledFor(logging.DEBUG):
filtered_event_logger.debug(
Expand Down
26 changes: 26 additions & 0 deletions tests/events/test_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -822,6 +822,32 @@ def test_event_flagged_for_admins(self) -> None:
"unsigned": {"io.element.synapse.soft_failed": True},
},
)
self.assertEqual(
self.serialize(
MockEvent(
type="foo",
event_id="test",
room_id="!foo:bar",
content={"foo": "bar"},
internal_metadata={
"soft_failed": True,
"policy_server_spammy": True,
},
),
[],
True,
),
{
"type": "foo",
"event_id": "test",
"room_id": "!foo:bar",
"content": {"foo": "bar"},
"unsigned": {
"io.element.synapse.soft_failed": True,
"io.element.synapse.policy_server_spammy": True,
},
},
)

def test_make_serialize_config_for_admin_retains_other_fields(self) -> None:
non_default_config = SerializeEventConfig(
Expand Down
10 changes: 5 additions & 5 deletions tests/rest/client/test_relations.py
Original file line number Diff line number Diff line change
Expand Up @@ -1181,7 +1181,7 @@ def assert_annotations(bundled_aggregations: JsonDict) -> None:
bundled_aggregations,
)

self._test_bundled_aggregations(RelationTypes.REFERENCE, assert_annotations, 8)
self._test_bundled_aggregations(RelationTypes.REFERENCE, assert_annotations, 9)

def test_thread(self) -> None:
"""
Expand Down Expand Up @@ -1226,21 +1226,21 @@ def assert_thread(bundled_aggregations: JsonDict) -> None:

# The "user" sent the root event and is making queries for the bundled
# aggregations: they have participated.
self._test_bundled_aggregations(RelationTypes.THREAD, _gen_assert(True), 9)
self._test_bundled_aggregations(RelationTypes.THREAD, _gen_assert(True), 10)
# The "user2" sent replies in the thread and is making queries for the
# bundled aggregations: they have participated.
#
# Note that this re-uses some cached values, so the total number of
# queries is much smaller.
self._test_bundled_aggregations(
RelationTypes.THREAD, _gen_assert(True), 6, access_token=self.user2_token
RelationTypes.THREAD, _gen_assert(True), 7, access_token=self.user2_token
)

# A user with no interactions with the thread: they have not participated.
user3_id, user3_token = self._create_user("charlie")
self.helper.join(self.room, user=user3_id, tok=user3_token)
self._test_bundled_aggregations(
RelationTypes.THREAD, _gen_assert(False), 6, access_token=user3_token
RelationTypes.THREAD, _gen_assert(False), 7, access_token=user3_token
)

def test_thread_with_bundled_aggregations_for_latest(self) -> None:
Expand Down Expand Up @@ -1287,7 +1287,7 @@ def assert_thread(bundled_aggregations: JsonDict) -> None:
bundled_aggregations["latest_event"].get("unsigned"),
)

self._test_bundled_aggregations(RelationTypes.THREAD, assert_thread, 9)
self._test_bundled_aggregations(RelationTypes.THREAD, assert_thread, 10)

def test_nested_thread(self) -> None:
"""
Expand Down
7 changes: 7 additions & 0 deletions tests/test_utils/event_injection.py
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,13 @@ async def create_event(
builder, prev_event_ids=prev_event_ids
)

# Copy over writable internal_metadata, if set
# Dev note: This isn't everything that's writable. `for k,v` doesn't work here :(
if kwargs.get("internal_metadata", {}).get("soft_failed", False):
event.internal_metadata.soft_failed = True
if kwargs.get("internal_metadata", {}).get("policy_server_spammy", False):
event.internal_metadata.policy_server_spammy = True

context = await unpersisted_context.persist(event)

return event, context
Expand Down
Loading
Loading