Allow admins to see soft failed events (if they want to) (#18238)
This commit is contained in:
1
changelog.d/18238.feature
Normal file
1
changelog.d/18238.feature
Normal file
@@ -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.
|
||||
@@ -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)
|
||||
|
||||
25
docs/admin_api/client_server_api_extensions.md
Normal file
25
docs/admin_api/client_server_api_extensions.md
Normal file
@@ -0,0 +1,25 @@
|
||||
# Client-Server API Extensions
|
||||
|
||||
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`
|
||||
@@ -302,6 +302,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:
|
||||
|
||||
@@ -555,6 +555,9 @@ class ApplicationServiceApi(SimpleHttpClient):
|
||||
)
|
||||
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
|
||||
|
||||
@@ -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,
|
||||
@@ -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(
|
||||
@@ -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
|
||||
] = []
|
||||
@@ -576,6 +590,15 @@ class EventClientSerializer:
|
||||
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 = {}
|
||||
|
||||
22
synapse/storage/admin_client_config.py
Normal file
22
synapse/storage/admin_client_config.py
Normal file
@@ -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
|
||||
)
|
||||
@@ -37,6 +37,7 @@ from synapse.api.constants import AccountDataTypes
|
||||
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,
|
||||
@@ -578,6 +579,21 @@ class AccountDataWorkerStore(PushRulesWorkerStore, CacheInvalidationWorkerStore)
|
||||
)
|
||||
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,
|
||||
|
||||
@@ -48,7 +48,13 @@ from synapse.logging.opentracing import trace
|
||||
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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -34,11 +34,13 @@ from synapse.events.utils import (
|
||||
_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
|
||||
|
||||
|
||||
@@ -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):
|
||||
@@ -637,9 +645,18 @@ class CloneEventTestCase(stdlib_unittest.TestCase):
|
||||
|
||||
|
||||
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:
|
||||
@@ -758,6 +775,78 @@ class SerializeEventTestCase(stdlib_unittest.TestCase):
|
||||
["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:
|
||||
|
||||
@@ -1181,7 +1181,7 @@ class BundledAggregationsTestCase(BaseRelationsTestCase):
|
||||
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:
|
||||
"""
|
||||
@@ -1226,21 +1226,21 @@ class BundledAggregationsTestCase(BaseRelationsTestCase):
|
||||
|
||||
# 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:
|
||||
@@ -1287,7 +1287,7 @@ class BundledAggregationsTestCase(BaseRelationsTestCase):
|
||||
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:
|
||||
"""
|
||||
|
||||
Reference in New Issue
Block a user