Skip to content

Commit 46a4f9d

Browse files
committed
Dedicated API for MAS -> Synapse communication
1 parent 5ea2cf2 commit 46a4f9d

File tree

7 files changed

+627
-3
lines changed

7 files changed

+627
-3
lines changed

synapse/_pydantic_compat.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@
4848
conint,
4949
constr,
5050
parse_obj_as,
51+
root_validator,
5152
validator,
5253
)
5354
from pydantic.v1.error_wrappers import ErrorWrapper
@@ -68,6 +69,7 @@
6869
conint,
6970
constr,
7071
parse_obj_as,
72+
root_validator,
7173
validator,
7274
)
7375
from pydantic.error_wrappers import ErrorWrapper
@@ -92,4 +94,5 @@
9294
"StrictStr",
9395
"ValidationError",
9496
"validator",
97+
"root_validator",
9598
)

synapse/api/auth/msc3861_delegated.py

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -369,6 +369,12 @@ async def _introspect_token(
369369
async def is_server_admin(self, requester: Requester) -> bool:
370370
return "urn:synapse:admin:*" in requester.scope
371371

372+
def _is_access_token_the_admin_token(self, token: str) -> bool:
373+
admin_token = self._admin_token()
374+
if admin_token is None:
375+
return False
376+
return token == admin_token
377+
372378
async def get_user_by_req(
373379
self,
374380
request: SynapseRequest,
@@ -434,7 +440,7 @@ async def _wrapped_get_user_by_req(
434440
requester = await self.get_user_by_access_token(access_token, allow_expired)
435441

436442
# Do not record requests from MAS using the virtual `__oidc_admin` user.
437-
if access_token != self._admin_token():
443+
if not self._is_access_token_the_admin_token(access_token):
438444
await self._record_request(request, requester)
439445

440446
if not allow_guest and requester.is_guest:
@@ -470,13 +476,25 @@ async def get_user_by_req_experimental_feature(
470476

471477
raise UnrecognizedRequestError(code=404)
472478

479+
def is_request_using_the_admin_token(self, request: SynapseRequest) -> bool:
480+
"""
481+
Check if the request is using the admin token.
482+
483+
Args:
484+
request: The request to check.
485+
486+
Returns:
487+
True if the request is using the admin token, False otherwise.
488+
"""
489+
access_token = self.get_access_token_from_request(request)
490+
return self._is_access_token_the_admin_token(access_token)
491+
473492
async def get_user_by_access_token(
474493
self,
475494
token: str,
476495
allow_expired: bool = False,
477496
) -> Requester:
478-
admin_token = self._admin_token()
479-
if admin_token is not None and token == admin_token:
497+
if self._is_access_token_the_admin_token(token):
480498
# XXX: This is a temporary solution so that the admin API can be called by
481499
# the OIDC provider. This will be removed once we have OIDC client
482500
# credentials grant support in matrix-authentication-service.

synapse/rest/synapse/client/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
from synapse.rest.synapse.client.rendezvous import MSC4108RendezvousSessionResource
3131
from synapse.rest.synapse.client.sso_register import SsoRegisterResource
3232
from synapse.rest.synapse.client.unsubscribe import UnsubscribeResource
33+
from synapse.rest.synapse.mas import MasResource
3334

3435
if TYPE_CHECKING:
3536
from synapse.server import HomeServer
@@ -60,6 +61,7 @@ def build_synapse_client_resource_tree(hs: "HomeServer") -> Mapping[str, Resourc
6061
from synapse.rest.synapse.client.jwks import JwksResource
6162

6263
resources["/_synapse/jwks"] = JwksResource(hs)
64+
resources["/_synapse/mas"] = MasResource(hs)
6365

6466
# provider-specific SSO bits. Only load these if they are enabled, since they
6567
# rely on optional dependencies.

synapse/rest/synapse/mas/__init__.py

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
#
2+
# This file is licensed under the Affero General Public License (AGPL) version 3.
3+
#
4+
# Copyright (C) 2025 New Vector, Ltd
5+
#
6+
# This program is free software: you can redistribute it and/or modify
7+
# it under the terms of the GNU Affero General Public License as
8+
# published by the Free Software Foundation, either version 3 of the
9+
# License, or (at your option) any later version.
10+
#
11+
# See the GNU Affero General Public License for more details:
12+
# <https://www.gnu.org/licenses/agpl_3.0.html>.
13+
#
14+
#
15+
16+
17+
import logging
18+
from typing import TYPE_CHECKING
19+
20+
from twisted.web.resource import Resource
21+
22+
from synapse.rest.synapse.mas.devices import (
23+
MasCreateDeviceResource,
24+
MasSyncDevicesResource,
25+
MasUpdateDeviceDisplayNameResource,
26+
)
27+
from synapse.rest.synapse.mas.users import (
28+
MasAllowCrossSigningResetResource,
29+
MasDeleteUserResource,
30+
MasIsLocalpartAvailableResource,
31+
MasProvisionUserResource,
32+
MasQueryUserResource,
33+
MasReactivateUserResource,
34+
MasSetDisplayNameResource,
35+
MasUnsetDisplayNameResource,
36+
)
37+
38+
if TYPE_CHECKING:
39+
from synapse.server import HomeServer
40+
41+
42+
logger = logging.getLogger(__name__)
43+
44+
45+
class MasResource(Resource):
46+
def __init__(self, hs: "HomeServer"):
47+
Resource.__init__(self)
48+
self.putChild(b"query_user", MasQueryUserResource(hs))
49+
self.putChild(b"provision_user", MasProvisionUserResource(hs))
50+
self.putChild(b"is_localpart_available", MasIsLocalpartAvailableResource(hs))
51+
self.putChild(b"delete_user", MasDeleteUserResource(hs))
52+
self.putChild(b"create_device", MasCreateDeviceResource(hs))
53+
self.putChild(
54+
b"update_device_display_name", MasUpdateDeviceDisplayNameResource(hs)
55+
)
56+
self.putChild(b"sync_devices", MasSyncDevicesResource(hs))
57+
self.putChild(b"reactivate_user", MasReactivateUserResource(hs))
58+
self.putChild(b"set_displayname", MasSetDisplayNameResource(hs))
59+
self.putChild(b"unset_displayname", MasUnsetDisplayNameResource(hs))
60+
self.putChild(
61+
b"allow_cross_signing_reset", MasAllowCrossSigningResetResource(hs)
62+
)

synapse/rest/synapse/mas/_base.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
#
2+
# This file is licensed under the Affero General Public License (AGPL) version 3.
3+
#
4+
# Copyright (C) 2025 New Vector, Ltd
5+
#
6+
# This program is free software: you can redistribute it and/or modify
7+
# it under the terms of the GNU Affero General Public License as
8+
# published by the Free Software Foundation, either version 3 of the
9+
# License, or (at your option) any later version.
10+
#
11+
# See the GNU Affero General Public License for more details:
12+
# <https://www.gnu.org/licenses/agpl_3.0.html>.
13+
#
14+
#
15+
16+
17+
from typing import TYPE_CHECKING, cast
18+
19+
from synapse.api.auth.msc3861_delegated import MSC3861DelegatedAuth
20+
from synapse.api.errors import SynapseError
21+
from synapse.http.server import DirectServeJsonResource
22+
23+
if TYPE_CHECKING:
24+
from synapse.app.generic_worker import GenericWorkerStore
25+
from synapse.http.site import SynapseRequest
26+
from synapse.server import HomeServer
27+
28+
29+
class MasBaseResource(DirectServeJsonResource):
30+
def __init__(self, hs: "HomeServer"):
31+
DirectServeJsonResource.__init__(self, extract_context=True)
32+
auth = hs.get_auth()
33+
assert isinstance(auth, MSC3861DelegatedAuth)
34+
self.msc3861_auth = auth
35+
self.store = cast("GenericWorkerStore", hs.get_datastores().main)
36+
self.hostname = hs.hostname
37+
38+
def assert_mas_request(self, request: "SynapseRequest") -> None:
39+
if not self.msc3861_auth.is_request_using_the_admin_token(request):
40+
raise SynapseError(403, "This endpoint must only be called by MAS")

synapse/rest/synapse/mas/devices.py

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
#
2+
# This file is licensed under the Affero General Public License (AGPL) version 3.
3+
#
4+
# Copyright (C) 2025 New Vector, Ltd
5+
#
6+
# This program is free software: you can redistribute it and/or modify
7+
# it under the terms of the GNU Affero General Public License as
8+
# published by the Free Software Foundation, either version 3 of the
9+
# License, or (at your option) any later version.
10+
#
11+
# See the GNU Affero General Public License for more details:
12+
# <https://www.gnu.org/licenses/agpl_3.0.html>.
13+
#
14+
#
15+
16+
import logging
17+
from http import HTTPStatus
18+
from typing import TYPE_CHECKING, Optional, Tuple
19+
20+
from synapse._pydantic_compat import BaseModel, StrictStr
21+
from synapse.http.servlet import parse_and_validate_json_object_from_request
22+
from synapse.types import JsonDict, UserID
23+
24+
if TYPE_CHECKING:
25+
from synapse.http.site import SynapseRequest
26+
from synapse.server import HomeServer
27+
28+
29+
from ._base import MasBaseResource
30+
31+
logger = logging.getLogger(__name__)
32+
33+
34+
class MasCreateDeviceResource(MasBaseResource):
35+
def __init__(self, hs: "HomeServer"):
36+
MasBaseResource.__init__(self, hs)
37+
38+
self.device_handler = hs.get_device_handler()
39+
40+
class PostBody(BaseModel):
41+
localpart: StrictStr
42+
device_id: StrictStr
43+
display_name: Optional[StrictStr]
44+
45+
async def _async_render_POST(
46+
self, request: "SynapseRequest"
47+
) -> Tuple[int, JsonDict]:
48+
self.assert_mas_request(request)
49+
50+
body = parse_and_validate_json_object_from_request(request, self.PostBody)
51+
user_id = UserID(body.localpart, self.hostname)
52+
53+
inserted = await self.device_handler.upsert_device(
54+
user_id=str(user_id),
55+
device_id=body.device_id,
56+
display_name=body.display_name,
57+
)
58+
59+
return HTTPStatus.CREATED if inserted else HTTPStatus.OK, {}
60+
61+
62+
class MasUpdateDeviceDisplayNameResource(MasBaseResource):
63+
def __init__(self, hs: "HomeServer"):
64+
MasBaseResource.__init__(self, hs)
65+
66+
self.device_handler = hs.get_device_handler()
67+
68+
class PostBody(BaseModel):
69+
localpart: StrictStr
70+
device_id: StrictStr
71+
display_name: StrictStr
72+
73+
async def _async_render_POST(
74+
self, request: "SynapseRequest"
75+
) -> Tuple[int, JsonDict]:
76+
self.assert_mas_request(request)
77+
78+
body = parse_and_validate_json_object_from_request(request, self.PostBody)
79+
user_id = UserID(body.localpart, self.hostname)
80+
81+
await self.device_handler.update_device(
82+
user_id=str(user_id),
83+
device_id=body.device_id,
84+
content={"display_name": body.display_name},
85+
)
86+
87+
return HTTPStatus.OK, {}
88+
89+
90+
class MasSyncDevicesResource(MasBaseResource):
91+
def __init__(self, hs: "HomeServer"):
92+
MasBaseResource.__init__(self, hs)
93+
94+
self.device_handler = hs.get_device_handler()
95+
96+
class PostBody(BaseModel):
97+
localpart: StrictStr
98+
devices: set[StrictStr]
99+
100+
async def _async_render_GET(
101+
self, request: "SynapseRequest"
102+
) -> Tuple[int, JsonDict]:
103+
self.assert_mas_request(request)
104+
105+
body = parse_and_validate_json_object_from_request(request, self.PostBody)
106+
user_id = UserID(body.localpart, self.hostname)
107+
108+
current_devices = await self.store.get_devices_by_user(user_id=str(user_id))
109+
current_devices_list = set(current_devices.keys())
110+
target_device_list = set(body.devices)
111+
112+
to_add = target_device_list - current_devices_list
113+
to_delete = current_devices_list - target_device_list
114+
115+
# Log what we're about to do, as this can be an expensive operation
116+
if to_add and to_delete:
117+
logger.info(
118+
"Syncing %d devices for user %s will add %d devices and delete %d devices",
119+
len(target_device_list),
120+
user_id,
121+
len(to_add),
122+
len(to_delete),
123+
)
124+
elif to_add:
125+
logger.info(
126+
"Syncing %d devices for user %s will add %d devices",
127+
len(target_device_list),
128+
user_id,
129+
len(to_add),
130+
)
131+
elif to_delete:
132+
logger.info(
133+
"Syncing %d devices for user %s will delete %d devices",
134+
len(target_device_list),
135+
user_id,
136+
len(to_delete),
137+
)
138+
139+
if to_delete:
140+
await self.device_handler.delete_devices(
141+
user_id=str(user_id), device_ids=to_delete
142+
)
143+
144+
for device_id in to_add:
145+
await self.device_handler.upsert_device(
146+
user_id=str(user_id),
147+
device_id=device_id,
148+
)
149+
150+
return 200, {}

0 commit comments

Comments
 (0)