Skip to content

Commit 8a4e2e8

Browse files
Dedicated MAS API (#18520)
This introduces a dedicated API for MAS to consume. Companion PR on the MAS side: element-hq/matrix-authentication-service#4801 This has a few advantages over the previous admin API: - it works on workers (this will be documented once we stabilise MSC3861 as a whole) - it is more efficient because more focused - it propagates trace contexts from MAS - it is only accessible to MAS (through the shared secret) and will let us remove the weird hack that made this token 'admin' with a ghost '@__oidc_admin:' user The next MAS version should support it, but will be opt-in. The version after that should use this new API by default --------- Co-authored-by: Eric Eastwood <erice@element.io>
1 parent 875269e commit 8a4e2e8

File tree

12 files changed

+2997
-3
lines changed

12 files changed

+2997
-3
lines changed

changelog.d/18520.misc

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Dedicated internal API for Matrix Authentication Service to Synapse communication.

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: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
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+
MasDeleteDeviceResource,
24+
MasSyncDevicesResource,
25+
MasUpdateDeviceDisplayNameResource,
26+
MasUpsertDeviceResource,
27+
)
28+
from synapse.rest.synapse.mas.users import (
29+
MasAllowCrossSigningResetResource,
30+
MasDeleteUserResource,
31+
MasIsLocalpartAvailableResource,
32+
MasProvisionUserResource,
33+
MasQueryUserResource,
34+
MasReactivateUserResource,
35+
MasSetDisplayNameResource,
36+
MasUnsetDisplayNameResource,
37+
)
38+
39+
if TYPE_CHECKING:
40+
from synapse.server import HomeServer
41+
42+
43+
logger = logging.getLogger(__name__)
44+
45+
46+
class MasResource(Resource):
47+
"""
48+
Provides endpoints for MAS to manage user accounts and devices.
49+
50+
All endpoints are mounted under the path `/_synapse/mas/` and only work
51+
using the MAS admin token.
52+
"""
53+
54+
def __init__(self, hs: "HomeServer"):
55+
Resource.__init__(self)
56+
self.putChild(b"query_user", MasQueryUserResource(hs))
57+
self.putChild(b"provision_user", MasProvisionUserResource(hs))
58+
self.putChild(b"is_localpart_available", MasIsLocalpartAvailableResource(hs))
59+
self.putChild(b"delete_user", MasDeleteUserResource(hs))
60+
self.putChild(b"upsert_device", MasUpsertDeviceResource(hs))
61+
self.putChild(b"delete_device", MasDeleteDeviceResource(hs))
62+
self.putChild(
63+
b"update_device_display_name", MasUpdateDeviceDisplayNameResource(hs)
64+
)
65+
self.putChild(b"sync_devices", MasSyncDevicesResource(hs))
66+
self.putChild(b"reactivate_user", MasReactivateUserResource(hs))
67+
self.putChild(b"set_displayname", MasSetDisplayNameResource(hs))
68+
self.putChild(b"unset_displayname", MasUnsetDisplayNameResource(hs))
69+
self.putChild(
70+
b"allow_cross_signing_reset", MasAllowCrossSigningResetResource(hs)
71+
)

synapse/rest/synapse/mas/_base.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
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.errors import SynapseError
20+
from synapse.http.server import DirectServeJsonResource
21+
22+
if TYPE_CHECKING:
23+
from synapse.app.generic_worker import GenericWorkerStore
24+
from synapse.http.site import SynapseRequest
25+
from synapse.server import HomeServer
26+
27+
28+
class MasBaseResource(DirectServeJsonResource):
29+
def __init__(self, hs: "HomeServer"):
30+
# Importing this module requires authlib, which is an optional
31+
# dependency but required if msc3861 is enabled
32+
from synapse.api.auth.msc3861_delegated import MSC3861DelegatedAuth
33+
34+
DirectServeJsonResource.__init__(self, extract_context=True)
35+
auth = hs.get_auth()
36+
assert isinstance(auth, MSC3861DelegatedAuth)
37+
self.msc3861_auth = auth
38+
self.store = cast("GenericWorkerStore", hs.get_datastores().main)
39+
self.hostname = hs.hostname
40+
41+
def assert_request_is_from_mas(self, request: "SynapseRequest") -> None:
42+
"""Assert that the request is coming from MAS itself, not a regular user.
43+
44+
Throws a 403 if the request is not coming from MAS.
45+
"""
46+
if not self.msc3861_auth.is_request_using_the_admin_token(request):
47+
raise SynapseError(403, "This endpoint must only be called by MAS")

0 commit comments

Comments
 (0)