From 57538eb4d9fef09f4c3a234d51e34478f45b7917 Mon Sep 17 00:00:00 2001 From: Erik Johnston Date: Fri, 5 Jul 2024 13:02:35 +0100 Subject: [PATCH] Finish up work to allow per-user feature flags (#17392) Follows on from @H-Shay's great work at https://github.com/matrix-org/synapse/pull/15344 and MSC4026. Also enables its use for MSC3881, mainly as an easy but concrete example of how to use it. --- changelog.d/17392.misc | 1 + docs/admin_api/experimental_features.md | 17 ++-- synapse/rest/admin/experimental_features.py | 11 ++- synapse/rest/client/pusher.py | 29 ++++--- synapse/rest/client/versions.py | 20 ++++- .../databases/main/experimental_features.py | 64 +++++++++++++-- tests/push/test_http.py | 82 ++++++++++++++++++- tests/rest/admin/test_admin.py | 14 +--- 8 files changed, 189 insertions(+), 49 deletions(-) create mode 100644 changelog.d/17392.misc diff --git a/changelog.d/17392.misc b/changelog.d/17392.misc new file mode 100644 index 000000000..76e3976e2 --- /dev/null +++ b/changelog.d/17392.misc @@ -0,0 +1 @@ +Finish up work to allow per-user feature flags. diff --git a/docs/admin_api/experimental_features.md b/docs/admin_api/experimental_features.md index 07b630915..250cfc13a 100644 --- a/docs/admin_api/experimental_features.md +++ b/docs/admin_api/experimental_features.md @@ -1,21 +1,16 @@ # Experimental Features API This API allows a server administrator to enable or disable some experimental features on a per-user -basis. The currently supported features are: -- [MSC3026](https://github.com/matrix-org/matrix-spec-proposals/pull/3026): busy -presence state enabled -- [MSC3881](https://github.com/matrix-org/matrix-spec-proposals/pull/3881): enable remotely toggling push notifications -for another client -- [MSC3967](https://github.com/matrix-org/matrix-spec-proposals/pull/3967): do not require -UIA when first uploading cross-signing keys. - +basis. The currently supported features are: +- [MSC3881](https://github.com/matrix-org/matrix-spec-proposals/pull/3881): enable remotely toggling push notifications +for another client To use it, you will need to authenticate by providing an `access_token` for a server admin: see [Admin API](../usage/administration/admin_api/). ## Enabling/Disabling Features -This API allows a server administrator to enable experimental features for a given user. The request must +This API allows a server administrator to enable experimental features for a given user. The request must provide a body containing the user id and listing the features to enable/disable in the following format: ```json { @@ -35,7 +30,7 @@ PUT /_synapse/admin/v1/experimental_features/ ``` ## Listing Enabled Features - + To list which features are enabled/disabled for a given user send a request to the following API: ``` @@ -52,4 +47,4 @@ user like so: "msc3967": false } } -``` \ No newline at end of file +``` diff --git a/synapse/rest/admin/experimental_features.py b/synapse/rest/admin/experimental_features.py index c5a00c490..c1559c92f 100644 --- a/synapse/rest/admin/experimental_features.py +++ b/synapse/rest/admin/experimental_features.py @@ -31,7 +31,9 @@ from synapse.rest.admin import admin_patterns, assert_requester_is_admin from synapse.types import JsonDict, UserID if TYPE_CHECKING: - from synapse.server import HomeServer + from typing_extensions import assert_never + + from synapse.server import HomeServer, HomeServerConfig class ExperimentalFeature(str, Enum): @@ -39,9 +41,14 @@ class ExperimentalFeature(str, Enum): Currently supported per-user features """ - MSC3026 = "msc3026" MSC3881 = "msc3881" + def is_globally_enabled(self, config: "HomeServerConfig") -> bool: + if self is ExperimentalFeature.MSC3881: + return config.experimental.msc3881_enabled + + assert_never(self) + class ExperimentalFeaturesRestServlet(RestServlet): """ diff --git a/synapse/rest/client/pusher.py b/synapse/rest/client/pusher.py index 9957d2fcb..a455f95a2 100644 --- a/synapse/rest/client/pusher.py +++ b/synapse/rest/client/pusher.py @@ -32,6 +32,7 @@ from synapse.http.servlet import ( ) from synapse.http.site import SynapseRequest from synapse.push import PusherConfigException +from synapse.rest.admin.experimental_features import ExperimentalFeature from synapse.rest.client._base import client_patterns from synapse.rest.synapse.client.unsubscribe import UnsubscribeResource from synapse.types import JsonDict @@ -49,20 +50,22 @@ class PushersRestServlet(RestServlet): super().__init__() self.hs = hs self.auth = hs.get_auth() - self._msc3881_enabled = self.hs.config.experimental.msc3881_enabled + self._store = hs.get_datastores().main async def on_GET(self, request: SynapseRequest) -> Tuple[int, JsonDict]: requester = await self.auth.get_user_by_req(request) - user = requester.user + user_id = requester.user.to_string() - pushers = await self.hs.get_datastores().main.get_pushers_by_user_id( - user.to_string() + msc3881_enabled = await self._store.is_feature_enabled( + user_id, ExperimentalFeature.MSC3881 ) + pushers = await self.hs.get_datastores().main.get_pushers_by_user_id(user_id) + pusher_dicts = [p.as_dict() for p in pushers] for pusher in pusher_dicts: - if self._msc3881_enabled: + if msc3881_enabled: pusher["org.matrix.msc3881.enabled"] = pusher["enabled"] pusher["org.matrix.msc3881.device_id"] = pusher["device_id"] del pusher["enabled"] @@ -80,11 +83,15 @@ class PushersSetRestServlet(RestServlet): self.auth = hs.get_auth() self.notifier = hs.get_notifier() self.pusher_pool = self.hs.get_pusherpool() - self._msc3881_enabled = self.hs.config.experimental.msc3881_enabled + self._store = hs.get_datastores().main async def on_POST(self, request: SynapseRequest) -> Tuple[int, JsonDict]: requester = await self.auth.get_user_by_req(request) - user = requester.user + user_id = requester.user.to_string() + + msc3881_enabled = await self._store.is_feature_enabled( + user_id, ExperimentalFeature.MSC3881 + ) content = parse_json_object_from_request(request) @@ -95,7 +102,7 @@ class PushersSetRestServlet(RestServlet): and content["kind"] is None ): await self.pusher_pool.remove_pusher( - content["app_id"], content["pushkey"], user_id=user.to_string() + content["app_id"], content["pushkey"], user_id=user_id ) return 200, {} @@ -120,19 +127,19 @@ class PushersSetRestServlet(RestServlet): append = content["append"] enabled = True - if self._msc3881_enabled and "org.matrix.msc3881.enabled" in content: + if msc3881_enabled and "org.matrix.msc3881.enabled" in content: enabled = content["org.matrix.msc3881.enabled"] if not append: await self.pusher_pool.remove_pushers_by_app_id_and_pushkey_not_user( app_id=content["app_id"], pushkey=content["pushkey"], - not_user_id=user.to_string(), + not_user_id=user_id, ) try: await self.pusher_pool.add_or_update_pusher( - user_id=user.to_string(), + user_id=user_id, kind=content["kind"], app_id=content["app_id"], app_display_name=content["app_display_name"], diff --git a/synapse/rest/client/versions.py b/synapse/rest/client/versions.py index f42815813..e01e5f542 100644 --- a/synapse/rest/client/versions.py +++ b/synapse/rest/client/versions.py @@ -25,11 +25,11 @@ import logging import re from typing import TYPE_CHECKING, Tuple -from twisted.web.server import Request - from synapse.api.constants import RoomCreationPreset from synapse.http.server import HttpServer from synapse.http.servlet import RestServlet +from synapse.http.site import SynapseRequest +from synapse.rest.admin.experimental_features import ExperimentalFeature from synapse.types import JsonDict if TYPE_CHECKING: @@ -45,6 +45,8 @@ class VersionsRestServlet(RestServlet): def __init__(self, hs: "HomeServer"): super().__init__() self.config = hs.config + self.auth = hs.get_auth() + self.store = hs.get_datastores().main # Calculate these once since they shouldn't change after start-up. self.e2ee_forced_public = ( @@ -60,7 +62,17 @@ class VersionsRestServlet(RestServlet): in self.config.room.encryption_enabled_by_default_for_room_presets ) - def on_GET(self, request: Request) -> Tuple[int, JsonDict]: + async def on_GET(self, request: SynapseRequest) -> Tuple[int, JsonDict]: + msc3881_enabled = self.config.experimental.msc3881_enabled + + if self.auth.has_access_token(request): + requester = await self.auth.get_user_by_req(request) + user_id = requester.user.to_string() + + msc3881_enabled = await self.store.is_feature_enabled( + user_id, ExperimentalFeature.MSC3881 + ) + return ( 200, { @@ -124,7 +136,7 @@ class VersionsRestServlet(RestServlet): # TODO: this is no longer needed once unstable MSC3882 does not need to be supported: "org.matrix.msc3882": self.config.auth.login_via_existing_enabled, # Adds support for remotely enabling/disabling pushers, as per MSC3881 - "org.matrix.msc3881": self.config.experimental.msc3881_enabled, + "org.matrix.msc3881": msc3881_enabled, # Adds support for filtering /messages by event relation. "org.matrix.msc3874": self.config.experimental.msc3874_enabled, # Adds support for simple HTTP rendezvous as per MSC3886 diff --git a/synapse/storage/databases/main/experimental_features.py b/synapse/storage/databases/main/experimental_features.py index fbb98d8f6..d980c57fa 100644 --- a/synapse/storage/databases/main/experimental_features.py +++ b/synapse/storage/databases/main/experimental_features.py @@ -21,7 +21,11 @@ from typing import TYPE_CHECKING, Dict, FrozenSet, List, Tuple, cast -from synapse.storage.database import DatabasePool, LoggingDatabaseConnection +from synapse.storage.database import ( + DatabasePool, + LoggingDatabaseConnection, + LoggingTransaction, +) from synapse.storage.databases.main import CacheInvalidationWorkerStore from synapse.util.caches.descriptors import cached @@ -73,12 +77,54 @@ class ExperimentalFeaturesStore(CacheInvalidationWorkerStore): features: pairs of features and True/False for whether the feature should be enabled """ - for feature, enabled in features.items(): - await self.db_pool.simple_upsert( - table="per_user_experimental_features", - keyvalues={"feature": feature, "user_id": user}, - values={"enabled": enabled}, - insertion_values={"user_id": user, "feature": feature}, - ) - await self.invalidate_cache_and_stream("list_enabled_features", (user,)) + def set_features_for_user_txn(txn: LoggingTransaction) -> None: + for feature, enabled in features.items(): + self.db_pool.simple_upsert_txn( + txn, + table="per_user_experimental_features", + keyvalues={"feature": feature, "user_id": user}, + values={"enabled": enabled}, + insertion_values={"user_id": user, "feature": feature}, + ) + + self._invalidate_cache_and_stream( + txn, self.is_feature_enabled, (user, feature) + ) + + self._invalidate_cache_and_stream(txn, self.list_enabled_features, (user,)) + + return await self.db_pool.runInteraction( + "set_features_for_user", set_features_for_user_txn + ) + + @cached() + async def is_feature_enabled( + self, user_id: str, feature: "ExperimentalFeature" + ) -> bool: + """ + Checks to see if a given feature is enabled for the user + Args: + user_id: the user to be queried on + feature: the feature in question + Returns: + True if the feature is enabled, False if it is not or if the feature was + not found. + """ + + if feature.is_globally_enabled(self.hs.config): + return True + + # if it's not enabled globally, check if it is enabled per-user + res = await self.db_pool.simple_select_one_onecol( + table="per_user_experimental_features", + keyvalues={"user_id": user_id, "feature": feature}, + retcol="enabled", + allow_none=True, + desc="get_feature_enabled", + ) + + # None and false are treated the same + db_enabled = bool(res) + + return db_enabled diff --git a/tests/push/test_http.py b/tests/push/test_http.py index dce00d8b7..bcca47261 100644 --- a/tests/push/test_http.py +++ b/tests/push/test_http.py @@ -26,7 +26,8 @@ from twisted.test.proto_helpers import MemoryReactor import synapse.rest.admin from synapse.logging.context import make_deferred_yieldable from synapse.push import PusherConfig, PusherConfigException -from synapse.rest.client import login, push_rule, pusher, receipts, room +from synapse.rest.admin.experimental_features import ExperimentalFeature +from synapse.rest.client import login, push_rule, pusher, receipts, room, versions from synapse.server import HomeServer from synapse.types import JsonDict from synapse.util import Clock @@ -42,6 +43,7 @@ class HTTPPusherTests(HomeserverTestCase): receipts.register_servlets, push_rule.register_servlets, pusher.register_servlets, + versions.register_servlets, ] user_id = True hijack_auth = False @@ -969,6 +971,84 @@ class HTTPPusherTests(HomeserverTestCase): lookup_result.device_id, ) + def test_device_id_feature_flag(self) -> None: + """Tests that a pusher created with a given device ID shows that device ID in + GET /pushers requests when feature is enabled for the user + """ + user_id = self.register_user("user", "pass") + access_token = self.login("user", "pass") + + # We create the pusher with an HTTP request rather than with + # _make_user_with_pusher so that we can test the device ID is correctly set when + # creating a pusher via an API call. + self.make_request( + method="POST", + path="/pushers/set", + content={ + "kind": "http", + "app_id": "m.http", + "app_display_name": "HTTP Push Notifications", + "device_display_name": "pushy push", + "pushkey": "a@example.com", + "lang": "en", + "data": {"url": "http://example.com/_matrix/push/v1/notify"}, + }, + access_token=access_token, + ) + + # Look up the user info for the access token so we can compare the device ID. + store = self.hs.get_datastores().main + lookup_result = self.get_success(store.get_user_by_access_token(access_token)) + assert lookup_result is not None + + # Check field is not there before we enable the feature flag + channel = self.make_request("GET", "/pushers", access_token=access_token) + self.assertEqual(channel.code, 200) + self.assertEqual(len(channel.json_body["pushers"]), 1) + self.assertNotIn( + "org.matrix.msc3881.device_id", channel.json_body["pushers"][0] + ) + + self.get_success( + store.set_features_for_user(user_id, {ExperimentalFeature.MSC3881: True}) + ) + + # Get the user's devices and check it has the correct device ID. + channel = self.make_request("GET", "/pushers", access_token=access_token) + self.assertEqual(channel.code, 200) + self.assertEqual(len(channel.json_body["pushers"]), 1) + self.assertEqual( + channel.json_body["pushers"][0]["org.matrix.msc3881.device_id"], + lookup_result.device_id, + ) + + def test_msc3881_client_versions_flag(self) -> None: + """Tests that MSC3881 only appears in /versions if user has it enabled.""" + + user_id = self.register_user("user", "pass") + access_token = self.login("user", "pass") + + # Check feature is disabled in /versions + channel = self.make_request( + "GET", "/_matrix/client/versions", access_token=access_token + ) + self.assertEqual(channel.code, 200) + self.assertFalse(channel.json_body["unstable_features"]["org.matrix.msc3881"]) + + # Enable feature for user + self.get_success( + self.hs.get_datastores().main.set_features_for_user( + user_id, {ExperimentalFeature.MSC3881: True} + ) + ) + + # Check feature is now enabled in /versions for user + channel = self.make_request( + "GET", "/_matrix/client/versions", access_token=access_token + ) + self.assertEqual(channel.code, 200) + self.assertTrue(channel.json_body["unstable_features"]["org.matrix.msc3881"]) + @override_config({"push": {"jitter_delay": "10s"}}) def test_jitter(self) -> None: """Tests that enabling jitter actually delays sending push.""" diff --git a/tests/rest/admin/test_admin.py b/tests/rest/admin/test_admin.py index 5f6f7213b..6351326ff 100644 --- a/tests/rest/admin/test_admin.py +++ b/tests/rest/admin/test_admin.py @@ -384,7 +384,7 @@ class ExperimentalFeaturesTestCase(unittest.HomeserverTestCase): "PUT", url, content={ - "features": {"msc3026": True, "msc3881": True}, + "features": {"msc3881": True}, }, access_token=self.admin_user_tok, ) @@ -399,10 +399,6 @@ class ExperimentalFeaturesTestCase(unittest.HomeserverTestCase): access_token=self.admin_user_tok, ) self.assertEqual(channel.code, 200) - self.assertEqual( - True, - channel.json_body["features"]["msc3026"], - ) self.assertEqual( True, channel.json_body["features"]["msc3881"], @@ -413,7 +409,7 @@ class ExperimentalFeaturesTestCase(unittest.HomeserverTestCase): channel = self.make_request( "PUT", url, - content={"features": {"msc3026": False}}, + content={"features": {"msc3881": False}}, access_token=self.admin_user_tok, ) self.assertEqual(channel.code, 200) @@ -429,10 +425,6 @@ class ExperimentalFeaturesTestCase(unittest.HomeserverTestCase): self.assertEqual(channel.code, 200) self.assertEqual( False, - channel.json_body["features"]["msc3026"], - ) - self.assertEqual( - True, channel.json_body["features"]["msc3881"], ) @@ -441,7 +433,7 @@ class ExperimentalFeaturesTestCase(unittest.HomeserverTestCase): channel = self.make_request( "PUT", url, - content={"features": {"msc3026": False}}, + content={"features": {"msc3881": False}}, access_token=self.admin_user_tok, ) self.assertEqual(channel.code, 200)