Skip to content
This repository was archived by the owner on Apr 26, 2024. It is now read-only.

Commit 89f6fb0

Browse files
authored
Add an admin API endpoint to support per-user feature flags (#15344)
1 parent eb6f8dc commit 89f6fb0

File tree

9 files changed

+408
-0
lines changed

9 files changed

+408
-0
lines changed

changelog.d/15344.feature

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Add an admin API endpoint to support per-user feature flags.
+54
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
# Experimental Features API
2+
3+
This API allows a server administrator to enable or disable some experimental features on a per-user
4+
basis. Currently supported features are [msc3026](https://github.com/matrix-org/matrix-spec-proposals/pull/3026): busy
5+
presence state enabled, [msc2654](https://github.com/matrix-org/matrix-spec-proposals/pull/2654): enable unread counts,
6+
[msc3881](https://github.com/matrix-org/matrix-spec-proposals/pull/3881): enable remotely toggling push notifications
7+
for another client, and [msc3967](https://github.com/matrix-org/matrix-spec-proposals/pull/3967): do not require
8+
UIA when first uploading cross-signing keys.
9+
10+
11+
To use it, you will need to authenticate by providing an `access_token`
12+
for a server admin: see [Admin API](../usage/administration/admin_api/).
13+
14+
## Enabling/Disabling Features
15+
16+
This API allows a server administrator to enable experimental features for a given user. The request must
17+
provide a body containing the user id and listing the features to enable/disable in the following format:
18+
```json
19+
{
20+
"features": {
21+
"msc3026":true,
22+
"msc2654":true
23+
}
24+
}
25+
```
26+
where true is used to enable the feature, and false is used to disable the feature.
27+
28+
29+
The API is:
30+
31+
```
32+
PUT /_synapse/admin/v1/experimental_features/<user_id>
33+
```
34+
35+
## Listing Enabled Features
36+
37+
To list which features are enabled/disabled for a given user send a request to the following API:
38+
39+
```
40+
GET /_synapse/admin/v1/experimental_features/<user_id>
41+
```
42+
43+
It will return a list of possible features and indicate whether they are enabled or disabled for the
44+
user like so:
45+
```json
46+
{
47+
"features": {
48+
"msc3026": true,
49+
"msc2654": true,
50+
"msc3881": false,
51+
"msc3967": false
52+
}
53+
}
54+
```

synapse/_scripts/synapse_port_db.py

+1
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,7 @@
125125
"users": ["shadow_banned", "approved"],
126126
"un_partial_stated_event_stream": ["rejection_status_changed"],
127127
"users_who_share_rooms": ["share_private"],
128+
"per_user_experimental_features": ["enabled"],
128129
}
129130

130131

synapse/rest/admin/__init__.py

+2
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@
3939
EventReportDetailRestServlet,
4040
EventReportsRestServlet,
4141
)
42+
from synapse.rest.admin.experimental_features import ExperimentalFeaturesRestServlet
4243
from synapse.rest.admin.federation import (
4344
DestinationMembershipRestServlet,
4445
DestinationResetConnectionRestServlet,
@@ -292,6 +293,7 @@ def register_servlets(hs: "HomeServer", http_server: HttpServer) -> None:
292293
BackgroundUpdateEnabledRestServlet(hs).register(http_server)
293294
BackgroundUpdateRestServlet(hs).register(http_server)
294295
BackgroundUpdateStartJobRestServlet(hs).register(http_server)
296+
ExperimentalFeaturesRestServlet(hs).register(http_server)
295297

296298

297299
def register_servlets_for_client_rest_resource(
+119
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
# Copyright 2023 The Matrix.org Foundation C.I.C
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
16+
from enum import Enum
17+
from http import HTTPStatus
18+
from typing import TYPE_CHECKING, Dict, Tuple
19+
20+
from synapse.api.errors import SynapseError
21+
from synapse.http.servlet import RestServlet, parse_json_object_from_request
22+
from synapse.http.site import SynapseRequest
23+
from synapse.rest.admin import admin_patterns, assert_requester_is_admin
24+
from synapse.types import JsonDict, UserID
25+
26+
if TYPE_CHECKING:
27+
from synapse.server import HomeServer
28+
29+
30+
class ExperimentalFeature(str, Enum):
31+
"""
32+
Currently supported per-user features
33+
"""
34+
35+
MSC3026 = "msc3026"
36+
MSC2654 = "msc2654"
37+
MSC3881 = "msc3881"
38+
MSC3967 = "msc3967"
39+
40+
41+
class ExperimentalFeaturesRestServlet(RestServlet):
42+
"""
43+
Enable or disable experimental features for a user or determine which features are enabled
44+
for a given user
45+
"""
46+
47+
PATTERNS = admin_patterns("/experimental_features/(?P<user_id>[^/]*)")
48+
49+
def __init__(self, hs: "HomeServer"):
50+
super().__init__()
51+
self.auth = hs.get_auth()
52+
self.store = hs.get_datastores().main
53+
self.is_mine = hs.is_mine
54+
55+
async def on_GET(
56+
self,
57+
request: SynapseRequest,
58+
user_id: str,
59+
) -> Tuple[int, JsonDict]:
60+
"""
61+
List which features are enabled for a given user
62+
"""
63+
await assert_requester_is_admin(self.auth, request)
64+
65+
target_user = UserID.from_string(user_id)
66+
if not self.is_mine(target_user):
67+
raise SynapseError(
68+
HTTPStatus.BAD_REQUEST,
69+
"User must be local to check what experimental features are enabled.",
70+
)
71+
72+
enabled_features = await self.store.list_enabled_features(user_id)
73+
74+
user_features = {}
75+
for feature in ExperimentalFeature:
76+
if feature in enabled_features:
77+
user_features[feature] = True
78+
else:
79+
user_features[feature] = False
80+
return HTTPStatus.OK, {"features": user_features}
81+
82+
async def on_PUT(
83+
self, request: SynapseRequest, user_id: str
84+
) -> Tuple[HTTPStatus, Dict]:
85+
"""
86+
Enable or disable the provided features for the requester
87+
"""
88+
await assert_requester_is_admin(self.auth, request)
89+
90+
body = parse_json_object_from_request(request)
91+
92+
target_user = UserID.from_string(user_id)
93+
if not self.is_mine(target_user):
94+
raise SynapseError(
95+
HTTPStatus.BAD_REQUEST,
96+
"User must be local to enable experimental features.",
97+
)
98+
99+
features = body.get("features")
100+
if not features:
101+
raise SynapseError(
102+
HTTPStatus.BAD_REQUEST, "You must provide features to set."
103+
)
104+
105+
# validate the provided features
106+
validated_features = {}
107+
for feature, enabled in features.items():
108+
try:
109+
validated_feature = ExperimentalFeature(feature)
110+
validated_features[validated_feature] = enabled
111+
except ValueError:
112+
raise SynapseError(
113+
HTTPStatus.BAD_REQUEST,
114+
f"{feature!r} is not recognised as a valid experimental feature.",
115+
)
116+
117+
await self.store.set_features_for_user(user_id, validated_features)
118+
119+
return HTTPStatus.OK, {}

synapse/storage/databases/main/__init__.py

+2
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@
4343
from .event_push_actions import EventPushActionsStore
4444
from .events_bg_updates import EventsBackgroundUpdatesStore
4545
from .events_forward_extremities import EventForwardExtremitiesStore
46+
from .experimental_features import ExperimentalFeaturesStore
4647
from .filtering import FilteringWorkerStore
4748
from .keys import KeyStore
4849
from .lock import LockStore
@@ -82,6 +83,7 @@
8283

8384
class DataStore(
8485
EventsBackgroundUpdatesStore,
86+
ExperimentalFeaturesStore,
8587
DeviceStore,
8688
RoomMemberStore,
8789
RoomStore,
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
# Copyright 2023 The Matrix.org Foundation C.I.C
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
from typing import TYPE_CHECKING, Dict
16+
17+
from synapse.storage.database import DatabasePool, LoggingDatabaseConnection
18+
from synapse.storage.databases.main import CacheInvalidationWorkerStore
19+
from synapse.types import StrCollection
20+
from synapse.util.caches.descriptors import cached
21+
22+
if TYPE_CHECKING:
23+
from synapse.rest.admin.experimental_features import ExperimentalFeature
24+
from synapse.server import HomeServer
25+
26+
27+
class ExperimentalFeaturesStore(CacheInvalidationWorkerStore):
28+
def __init__(
29+
self,
30+
database: DatabasePool,
31+
db_conn: LoggingDatabaseConnection,
32+
hs: "HomeServer",
33+
) -> None:
34+
super().__init__(database, db_conn, hs)
35+
36+
@cached()
37+
async def list_enabled_features(self, user_id: str) -> StrCollection:
38+
"""
39+
Checks to see what features are enabled for a given user
40+
Args:
41+
user:
42+
the user to be queried on
43+
Returns:
44+
the features currently enabled for the user
45+
"""
46+
enabled = await self.db_pool.simple_select_list(
47+
"per_user_experimental_features",
48+
{"user_id": user_id, "enabled": True},
49+
["feature"],
50+
)
51+
52+
return [feature["feature"] for feature in enabled]
53+
54+
async def set_features_for_user(
55+
self,
56+
user: str,
57+
features: Dict["ExperimentalFeature", bool],
58+
) -> None:
59+
"""
60+
Enables or disables features for a given user
61+
Args:
62+
user:
63+
the user for whom to enable/disable the features
64+
features:
65+
pairs of features and True/False for whether the feature should be enabled
66+
"""
67+
for feature, enabled in features.items():
68+
await self.db_pool.simple_upsert(
69+
table="per_user_experimental_features",
70+
keyvalues={"feature": feature, "user_id": user},
71+
values={"enabled": enabled},
72+
insertion_values={"user_id": user, "feature": feature},
73+
)
74+
75+
await self.invalidate_cache_and_stream("list_enabled_features", (user,))
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
/* Copyright 2023 The Matrix.org Foundation C.I.C
2+
*
3+
* Licensed under the Apache License, Version 2.0 (the "License");
4+
* you may not use this file except in compliance with the License.
5+
* You may obtain a copy of the License at
6+
*
7+
* http://www.apache.org/licenses/LICENSE-2.0
8+
*
9+
* Unless required by applicable law or agreed to in writing, software
10+
* distributed under the License is distributed on an "AS IS" BASIS,
11+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
* See the License for the specific language governing permissions and
13+
* limitations under the License.
14+
*/
15+
16+
-- Table containing experimental features and whether they are enabled for a given user
17+
CREATE TABLE per_user_experimental_features (
18+
-- The User ID to check/set the feature for
19+
user_id TEXT NOT NULL,
20+
-- Contains features to be enabled/disabled
21+
feature TEXT NOT NULL,
22+
-- whether the feature is enabled/disabled for a given user, defaults to disabled
23+
enabled BOOLEAN DEFAULT FALSE,
24+
FOREIGN KEY (user_id) REFERENCES users(name),
25+
PRIMARY KEY (user_id, feature)
26+
);
27+

0 commit comments

Comments
 (0)