Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
d1c73e7
Allow admins to see soft failed events
turt2live Mar 13, 2025
a855b55
changelog
turt2live Mar 13, 2025
8f2fa30
Attempt to fix linting
turt2live Mar 13, 2025
331bc7c
Empty commit to fix CI
turt2live Mar 13, 2025
b453b1a
Bump db txn expected count in relations tests
anoadragon453 Mar 14, 2025
8e823be
Merge branch 'develop' into travis/admin-soft-fail
turt2live Jun 19, 2025
a12af45
Switch to a general concept of "CS API extensions" on a per-user basis
turt2live Jun 19, 2025
efa5ad9
Reset aggregations counts
turt2live Jun 19, 2025
24c809f
Add some untested tests
turt2live Jun 19, 2025
f24386a
Attempt to fix linting
turt2live Jun 19, 2025
38a8937
kick ci
turt2live Jun 19, 2025
043bd86
I guess the CI doesn't want us to do that
turt2live Jun 19, 2025
99b9ee2
oops
turt2live Jun 19, 2025
8908312
Attempt to fix linting
turt2live Jun 19, 2025
1262b10
kick ci
turt2live Jun 19, 2025
7344990
actually render new docs
turt2live Jun 19, 2025
f967bbb
Appease linter
turt2live Jun 19, 2025
b2e4a63
Attempt to fix linting
turt2live Jun 19, 2025
88b4723
kick ci
turt2live Jun 19, 2025
1c7e7f1
Create internal metadata properly in tests
turt2live Jun 19, 2025
2800943
Bump db txn count in tests
turt2live Jun 19, 2025
b38c208
More txn count bumps
turt2live Jun 19, 2025
25ef8dd
await
turt2live Jun 19, 2025
37a38f8
fix internal_metadata?
turt2live Jun 19, 2025
7e8e66e
Flip `if` order to reduce db transactions in the general case
turt2live Jun 19, 2025
bcf3a89
ok, so pop breaks things
turt2live Jun 19, 2025
5c4ba56
Maybe there's just a bunch of checks behind the scenes
turt2live Jun 19, 2025
61ba901
one more time
turt2live Jun 19, 2025
0d13255
Merge branch 'develop' into travis/admin-soft-fail
turt2live Jul 4, 2025
1d5e06b
copy and simplify
turt2live Jul 4, 2025
9cf1cd1
evolve
turt2live Jul 4, 2025
088a3d7
minor doc changes
turt2live Jul 4, 2025
77cdaf4
Use the correct auth handler
turt2live Jul 4, 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/18238.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
If enabled by the user, server admins will see [soft failed](https://spec.matrix.org/v1.13/server-server-api/#soft-failure) events over the Client-Server API.
1 change: 1 addition & 0 deletions docs/SUMMARY.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@
- [Users](admin_api/user_admin_api.md)
- [Server Version](admin_api/version_api.md)
- [Federation](usage/administration/admin_api/federation.md)
- [Client-Server API Extensions](admin_api/client_server_api_extensions.md)
- [Manhole](manhole.md)
- [Monitoring](metrics-howto.md)
- [Reporting Homeserver Usage Statistics](usage/administration/monitoring/reporting_homeserver_usage_statistics.md)
Expand Down
25 changes: 25 additions & 0 deletions docs/admin_api/client_server_api_extensions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# Client-Server API Extensions
Copy link
Contributor

Choose a reason for hiding this comment

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

Discussed in the #synapse-dev:matrix.org room


Server administrators can set special account data to change how the Client-Server API behaves for
their clients. Setting the account data, or having it already set, as a non-admin has no effect.

All configuration options can be set through the `io.element.synapse.admin_client_config` global
account data on the admin's user account.

Example:
```
PUT /_matrix/client/v3/user/{adminUserId}/account_data/io.element.synapse.admin_client_config
{
"return_soft_failed_events": true
}
```

## See soft failed events

Learn more about soft failure from [the spec](https://spec.matrix.org/v1.14/server-server-api/#soft-failure).

To receive soft failed events in APIs like `/sync` and `/messages`, set `return_soft_failed_events`
to `true` in the admin client config. When `false`, the normal behaviour of these endpoints is to
exclude soft failed events.

Default: `false`
3 changes: 3 additions & 0 deletions synapse/api/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -290,6 +290,9 @@ class AccountDataTypes:
MSC4155_INVITE_PERMISSION_CONFIG: Final = (
"org.matrix.msc4155.invite_permission_config"
)
# Synapse-specific behaviour. See "Client-Server API Extensions" documentation
# in Admin API for more information.
SYNAPSE_ADMIN_CLIENT_CONFIG: Final = "io.element.synapse.admin_client_config"


class HistoryVisibility:
Expand Down
3 changes: 3 additions & 0 deletions synapse/appservice/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -555,6 +555,9 @@ def _serialize(
)
and service.is_interested_in_user(e.state_key)
),
# Appservices are considered 'trusted' by the admin and should have
# applicable metadata on their events.
include_admin_metadata=True,
),
)
for e in events
Expand Down
23 changes: 23 additions & 0 deletions synapse/events/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -421,11 +421,21 @@ class SerializeEventConfig:
# False, that state will be removed from the event before it is returned.
# Otherwise, it will be kept.
include_stripped_room_state: bool = False
# When True, sets unsigned fields to help clients identify events which
# only server admins can see through other configuration. For example,
# whether an event was soft failed by the server.
include_admin_metadata: bool = False


_DEFAULT_SERIALIZE_EVENT_CONFIG = SerializeEventConfig()


def make_config_for_admin(existing: SerializeEventConfig) -> SerializeEventConfig:
# Set the options which are only available to server admins,
# and copy the rest.
return attr.evolve(existing, include_admin_metadata=True)


def serialize_event(
e: Union[JsonDict, EventBase],
time_now_ms: int,
Expand Down Expand Up @@ -528,6 +538,9 @@ 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

only_event_fields = config.only_event_fields
if only_event_fields:
if not isinstance(only_event_fields, list) or not all(
Expand All @@ -548,6 +561,7 @@ class EventClientSerializer:

def __init__(self, hs: "HomeServer") -> None:
self._store = hs.get_datastores().main
self._auth = hs.get_auth()
self._add_extra_fields_to_unsigned_client_event_callbacks: List[
ADD_EXTRA_FIELDS_TO_UNSIGNED_CLIENT_EVENT_CALLBACK
] = []
Expand Down Expand Up @@ -576,6 +590,15 @@ async def serialize_event(
if not isinstance(event, EventBase):
return event

# Force-enable server admin metadata because the only time an event with
# relevant metadata will be when the admin requested it via their admin
# client config account data. Also, it's "just" some `unsigned` fields, so
# shouldn't cause much in terms of problems to downstream consumers.
if config.requester is not None and await self._auth.is_server_admin(
config.requester
):
config = make_config_for_admin(config)

serialized_event = serialize_event(event, time_now, config=config)

new_unsigned = {}
Expand Down
22 changes: 22 additions & 0 deletions synapse/storage/admin_client_config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import logging
from typing import Optional

from synapse.types import JsonMapping

logger = logging.getLogger(__name__)


class AdminClientConfig:
"""Class to track various Synapse-specific admin-only client-impacting config options."""

def __init__(self, account_data: Optional[JsonMapping]):
# Allow soft-failed events to be returned down `/sync` and other
# client APIs. `io.element.synapse.soft_failed: true` is added to the
# `unsigned` portion of the event to inform clients that the event
# is soft-failed.
self.return_soft_failed_events: bool = False

if account_data:
self.return_soft_failed_events = account_data.get(
"return_soft_failed_events", False
)
16 changes: 16 additions & 0 deletions synapse/storage/databases/main/account_data.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
from synapse.api.errors import Codes, SynapseError
from synapse.replication.tcp.streams import AccountDataStream
from synapse.storage._base import db_to_json
from synapse.storage.admin_client_config import AdminClientConfig
from synapse.storage.database import (
DatabasePool,
LoggingDatabaseConnection,
Expand Down Expand Up @@ -578,6 +579,21 @@ async def get_invite_config_for_user(self, user_id: str) -> InviteRulesConfig:
)
return InviteRulesConfig(data)

async def get_admin_client_config_for_user(self, user_id: str) -> AdminClientConfig:
"""
Get the admin client configuration for the specified user.

The admin client config contains Synapse-specific settings that clients running
server admin accounts can use. They have no effect on non-admin users.

Args:
user_id: The user ID to get config for.
"""
data = await self.get_global_account_data_by_type_for_user(
user_id, AccountDataTypes.SYNAPSE_ADMIN_CLIENT_CONFIG
)
return AdminClientConfig(data)

def process_replication_rows(
self,
stream_name: str,
Expand Down
23 changes: 19 additions & 4 deletions synapse/visibility.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,13 @@
from synapse.storage.controllers import StorageControllers
from synapse.storage.databases.main import DataStore
from synapse.synapse_rust.events import event_visible_to_server
from synapse.types import RetentionPolicy, StateMap, StrCollection, get_domain_from_id
from synapse.types import (
RetentionPolicy,
StateMap,
StrCollection,
UserID,
get_domain_from_id,
)
from synapse.types.state import StateFilter
from synapse.util import Clock

Expand Down Expand Up @@ -106,9 +112,18 @@ async def filter_events_for_client(
of `user_id` at each event.
"""
# Filter out events that have been soft failed so that we don't relay them
# to clients.
events_before_filtering = events
events = [e for e in events if not e.internal_metadata.is_soft_failed()]
# to clients, unless they're a server admin and want that to happen.
#
# We copy the events list to guarantee any modifications we make will only
# happen within the function.
events_before_filtering = events.copy()
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))
):
events = [e for e in events if not e.internal_metadata.is_soft_failed()]
if len(events_before_filtering) != len(events):
if filtered_event_logger.isEnabledFor(logging.DEBUG):
filtered_event_logger.debug(
Expand Down
97 changes: 93 additions & 4 deletions tests/events/test_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,11 +34,13 @@
_split_field,
clone_event,
copy_and_fixup_power_levels_contents,
format_event_raw,
make_config_for_admin,
maybe_upsert_event_field,
prune_event,
serialize_event,
)
from synapse.types import JsonDict
from synapse.types import JsonDict, create_requester
from synapse.util.frozenutils import freeze


Expand All @@ -49,7 +51,13 @@ def MockEvent(**kwargs: Any) -> EventBase:
kwargs["type"] = "fake_type"
if "content" not in kwargs:
kwargs["content"] = {}
return make_event_from_dict(kwargs)

# Move internal metadata out so we can call make_event properly
internal_metadata = kwargs.get("internal_metadata")
if internal_metadata is not None:
kwargs.pop("internal_metadata")

return make_event_from_dict(kwargs, internal_metadata_dict=internal_metadata)


class TestMaybeUpsertEventField(stdlib_unittest.TestCase):
Expand Down Expand Up @@ -637,9 +645,18 @@ def test_unsigned_is_copied(self) -> None:


class SerializeEventTestCase(stdlib_unittest.TestCase):
def serialize(self, ev: EventBase, fields: Optional[List[str]]) -> JsonDict:
def serialize(
self,
ev: EventBase,
fields: Optional[List[str]],
include_admin_metadata: bool = False,
) -> JsonDict:
return serialize_event(
ev, 1479807801915, config=SerializeEventConfig(only_event_fields=fields)
ev,
1479807801915,
config=SerializeEventConfig(
only_event_fields=fields, include_admin_metadata=include_admin_metadata
),
)

def test_event_fields_works_with_keys(self) -> None:
Expand Down Expand Up @@ -758,6 +775,78 @@ def test_event_fields_fail_if_fields_not_str(self) -> None:
["room_id", 4], # type: ignore[list-item]
)

def test_default_serialize_config_excludes_admin_metadata(self) -> None:
# We just really don't want this to be set to True accidentally
self.assertFalse(SerializeEventConfig().include_admin_metadata)

def test_event_flagged_for_admins(self) -> None:
# Default behaviour should be *not* to include it
self.assertEqual(
self.serialize(
MockEvent(
type="foo",
event_id="test",
room_id="!foo:bar",
content={"foo": "bar"},
internal_metadata={"soft_failed": True},
),
[],
),
{
"type": "foo",
"event_id": "test",
"room_id": "!foo:bar",
"content": {"foo": "bar"},
"unsigned": {},
},
)

# When asked though, we should set it
self.assertEqual(
self.serialize(
MockEvent(
type="foo",
event_id="test",
room_id="!foo:bar",
content={"foo": "bar"},
internal_metadata={"soft_failed": True},
),
[],
True,
),
{
"type": "foo",
"event_id": "test",
"room_id": "!foo:bar",
"content": {"foo": "bar"},
"unsigned": {"io.element.synapse.soft_failed": True},
},
)

def test_make_serialize_config_for_admin_retains_other_fields(self) -> None:
non_default_config = SerializeEventConfig(
include_admin_metadata=False, # should be True in a moment
as_client_event=False, # default True
event_format=format_event_raw, # default format_event_for_client_v1
requester=create_requester("@example:example.org"), # default None
only_event_fields=["foo"], # default None
include_stripped_room_state=True, # default False
)
admin_config = make_config_for_admin(non_default_config)
self.assertEqual(
admin_config.as_client_event, non_default_config.as_client_event
)
self.assertEqual(admin_config.event_format, non_default_config.event_format)
self.assertEqual(admin_config.requester, non_default_config.requester)
self.assertEqual(
admin_config.only_event_fields, non_default_config.only_event_fields
)
self.assertEqual(
admin_config.include_stripped_room_state,
admin_config.include_stripped_room_state,
)
self.assertTrue(admin_config.include_admin_metadata)


class CopyPowerLevelsContentTestCase(stdlib_unittest.TestCase):
def setUp(self) -> None:
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, 6)
self._test_bundled_aggregations(RelationTypes.REFERENCE, assert_annotations, 8)

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), 6)
self._test_bundled_aggregations(RelationTypes.THREAD, _gen_assert(True), 9)
# 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), 3, access_token=self.user2_token
RelationTypes.THREAD, _gen_assert(True), 6, 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), 3, access_token=user3_token
RelationTypes.THREAD, _gen_assert(False), 6, 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, 6)
self._test_bundled_aggregations(RelationTypes.THREAD, assert_thread, 9)

def test_nested_thread(self) -> None:
"""
Expand Down
Loading