Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/19974.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Add experimental support for MSC4502: Targeted and unrestricted room member queries.
1 change: 1 addition & 0 deletions rust/src/config/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -73,4 +73,5 @@ pub struct ExperimentalConfig {
pub msc4491_enabled: bool,
pub msc4143_enabled: bool,
pub msc4446_enabled: bool,
pub msc4502_enabled: bool,
}
4 changes: 4 additions & 0 deletions rust/src/handlers/versions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -269,6 +269,9 @@ pub struct UnstableFeatureMap {
/// MSC4446: Allow moving the fully read marker backwards.
#[serde(rename = "com.beeper.msc4446")]
msc4446_enabled: bool,
/// MSC4502: Targeted and unrestricted room member queries
#[serde(rename = "io.element.msc4502")]
msc4502: bool,

// Whether new rooms will be set to encrypted or not (based on presets).
#[serde(rename = "io.element.e2ee_forced.public")]
Expand Down Expand Up @@ -320,6 +323,7 @@ pub fn synapse_config_to_global_unstable_feature_map(
msc4491_enabled: config.experimental.msc4491_enabled,
msc4143_enabled: config.experimental.msc4143_enabled,
msc4446_enabled: config.experimental.msc4446_enabled,
msc4502: config.experimental.msc4502_enabled,
e2ee_forced_public: config
.room
.encryption_enabled_by_default_for_room_presets
Expand Down
13 changes: 13 additions & 0 deletions synapse/appservice/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,10 @@
# user ID -> {device ID -> [algorithm]}
TransactionUnusedFallbackKeys = dict[str, dict[str, list[str]]]

# Scopes assignable to application services for extended privileges.
SCOPE_QUERY_ROOM_MEMBERSHIP = "urn:matrix:client:io.element.msc4502:rooms:is_joined"
KNOWN_SCOPES = frozenset({SCOPE_QUERY_ROOM_MEMBERSHIP})


class ApplicationServiceState(Enum):
DOWN = "down"
Expand Down Expand Up @@ -104,6 +108,7 @@ def __init__(
supports_unstable_ephemeral: bool = False,
msc3202_transaction_extensions: bool = False,
msc4190_device_management: bool = False,
scopes: Iterable[str] | None = None,
):
self.token = token
self.url = (
Expand Down Expand Up @@ -140,6 +145,11 @@ def __init__(
else:
self.protocols = set()

self.scopes = set(scopes) if scopes else set()
unknown_scopes = self.scopes - KNOWN_SCOPES
if unknown_scopes:
raise ValueError(f"Unknown application service scope(s): {unknown_scopes}")

self.rate_limited = rate_limited

def _check_namespaces(
Expand Down Expand Up @@ -379,6 +389,9 @@ def is_exclusive_user(self, user_id: str) -> bool:
def is_interested_in_protocol(self, protocol: str) -> bool:
return protocol in self.protocols

def has_scope(self, scope: str) -> bool:
return scope in self.scopes

def is_exclusive_alias(self, alias: str) -> bool:
return self._is_exclusive(ApplicationService.NS_ALIASES, alias)

Expand Down
9 changes: 9 additions & 0 deletions synapse/config/appservice.py
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,14 @@ def _load_appservice(
"The `io.element.msc4190` option should be true or false if specified."
)

# Opt-in list of scopes granted to this appservice for restricted C-S API
# functionality.
scopes = as_info.get("io.element.msc4502.scopes", [])
if not isinstance(scopes, list) or not all(isinstance(s, str) for s in scopes):
raise ValueError(
"The `io.element.msc4502.scopes` option should be a list of strings if specified."
)

return ApplicationService(
token=as_info["as_token"],
url=as_info["url"],
Expand All @@ -213,4 +221,5 @@ def _load_appservice(
supports_ephemeral=supports_ephemeral,
msc3202_transaction_extensions=msc3202_transaction_extensions,
msc4190_device_management=msc4190_enabled,
scopes=scopes,
)
3 changes: 3 additions & 0 deletions synapse/config/experimental.py
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,9 @@ def read_config(
# See https://github.com/element-hq/synapse/issues/19524
self.msc4370_enabled = experimental.get("msc4370_enabled", False)

# MSC4502: Targeted and unrestricted room member queries
self.msc4502_enabled: bool = experimental.get("msc4502_enabled", False)

auth_delegated = (config.get("matrix_authentication_service") or {}).get(
"enabled", False
)
Expand Down
2 changes: 2 additions & 0 deletions synapse/rest/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@
retention,
room,
room_keys,
room_membership,
room_upgrade_rest_servlet,
sendtodevice,
sync,
Expand Down Expand Up @@ -128,6 +129,7 @@
rendezvous.register_servlets,
auth_metadata.register_servlets,
thread_subscriptions.register_servlets,
room_membership.register_servlets,
)

SERVLET_GROUPS: dict[str, Iterable[RegisterServletsFunc]] = {
Expand Down
133 changes: 133 additions & 0 deletions synapse/rest/client/room_membership.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
#
# This file is licensed under the Affero General Public License (AGPL) version 3.
#
# Copyright (C) 2026 Element Creations Ltd
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as
# published by the Free Software Foundation, either version 3 of the
# License, or (at your option) any later version.
#
# See the GNU Affero General Public License for more details:
# <https://www.gnu.org/licenses/agpl-3.0.html>.
#

import logging
import re
from http import HTTPStatus
from typing import TYPE_CHECKING

from synapse.api.constants import EventTypes, Membership
from synapse.api.errors import Codes, SynapseError
from synapse.appservice import SCOPE_QUERY_ROOM_MEMBERSHIP
from synapse.http.server import HttpServer
from synapse.http.servlet import RestServlet, parse_string
from synapse.http.site import SynapseRequest
from synapse.types import JsonDict, RoomID, UserID
from synapse.util.stringutils import parse_and_validate_server_name

if TYPE_CHECKING:
from synapse.server import HomeServer

logger = logging.getLogger(__name__)


class AppserviceRoomMembershipRestServlet(RestServlet):
PATTERNS = [
re.compile(
r"^/_matrix/client/unstable/io\.element\.msc4502/rooms/(?P<room_id>[^/]*)/is_joined$"
)
]
CATEGORY = "Client API requests"

def __init__(self, hs: "HomeServer"):
super().__init__()
self.auth = hs.get_auth()
self.store = hs.get_datastores().main
self.storage_controllers = hs.get_storage_controllers()
self.is_mine_id = hs.is_mine_id
self.is_mine_server_name = hs.is_mine_server_name

async def on_GET(
self, request: SynapseRequest, room_id: str
) -> tuple[int, JsonDict]:
requester = await self.auth.get_user_by_req(request, allow_guest=False)

app_service = (
self.store.get_app_service_by_id(requester.app_service_id)
if requester.app_service_id
else None
)

# Users and appservices can call this endpoint if they have the scope.
has_scope = (
app_service is not None
and app_service.has_scope(SCOPE_QUERY_ROOM_MEMBERSHIP)
) or SCOPE_QUERY_ROOM_MEMBERSHIP in requester.scope

if not has_scope:
raise SynapseError(
HTTPStatus.FORBIDDEN,
f"Missing {SCOPE_QUERY_ROOM_MEMBERSHIP} scope",
Codes.FORBIDDEN,
)

if not RoomID.is_valid(room_id):
raise SynapseError(
HTTPStatus.BAD_REQUEST, "Invalid room ID", Codes.INVALID_PARAM
)

mxid = parse_string(request, "mxid")
server_name = parse_string(request, "server_name")

if (mxid is None) == (server_name is None):
raise SynapseError(
HTTPStatus.BAD_REQUEST,
"Exactly one of 'mxid' or 'server_name' query parameters must be given",
Codes.MISSING_PARAM,
)

if mxid is not None:
if not UserID.is_valid(mxid):
raise SynapseError(
HTTPStatus.BAD_REQUEST,
"Invalid MXID: %s" % (mxid,),
Codes.INVALID_PARAM,
)
if self.is_mine_id(mxid):
(
membership,
_,
) = await self.store.get_local_current_membership_for_user_in_room(
mxid, room_id
)
joined = membership == Membership.JOIN
else:
event = await self.storage_controllers.state.get_current_state_event(
room_id, EventTypes.Member, mxid
)
joined = (
event is not None
and event.content.get("membership") == Membership.JOIN
)
else:
assert server_name is not None
try:
parse_and_validate_server_name(server_name)
except ValueError:
raise SynapseError(
HTTPStatus.BAD_REQUEST,
"Invalid server name: %s" % (server_name,),
Codes.INVALID_PARAM,
)
if self.is_mine_server_name(server_name):
joined = await self.store.is_locally_joined(room_id)
else:
joined = await self.store.is_host_joined(room_id, server_name)

return HTTPStatus.OK, {"joined": joined}


def register_servlets(hs: "HomeServer", http_server: HttpServer) -> None:
if hs.config.experimental.msc4502_enabled:
AppserviceRoomMembershipRestServlet(hs).register(http_server)
15 changes: 15 additions & 0 deletions synapse/storage/databases/main/roommember.py
Original file line number Diff line number Diff line change
Expand Up @@ -595,6 +595,21 @@ async def get_local_users_in_room(self, room_id: str) -> Sequence[str]:
desc="get_local_users_in_room",
)

@cached(max_entries=10000)
async def is_locally_joined(self, room_id: str) -> bool:
"""
Checks if any local user is currently joined to the given room.
"""
sql = """
SELECT 1 FROM local_current_membership
WHERE room_id = ? AND membership = ?
LIMIT 1
"""
rows = await self.db_pool.execute(
"is_locally_joined", sql, room_id, Membership.JOIN
)
return bool(rows)

async def get_local_users_related_to_room(
self, room_id: str
) -> list[tuple[str, str]]:
Expand Down
36 changes: 35 additions & 1 deletion tests/appservice/test_appservice.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,11 @@

from twisted.internet import defer

from synapse.appservice import ApplicationService, Namespace
from synapse.appservice import (
SCOPE_QUERY_ROOM_MEMBERSHIP,
ApplicationService,
Namespace,
)
from synapse.types import UserID

from tests import unittest
Expand Down Expand Up @@ -257,3 +261,33 @@ def test_member_list_match(self) -> Generator["defer.Deferred[Any]", object, Non
)
)
)


class ApplicationServiceScopesTestCase(unittest.TestCase):
def test_has_no_scopes_by_default(self) -> None:
service = ApplicationService(
id="unique_identifier",
sender=UserID.from_string("@as:test"),
token="some_token",
)
self.assertEqual(len(service.scopes), 0)
self.assertFalse(service.has_scope(SCOPE_QUERY_ROOM_MEMBERSHIP))

def test_has_valid_scope_if_specified(self) -> None:
service = ApplicationService(
id="unique_identifier",
sender=UserID.from_string("@as:test"),
token="some_token",
scopes=[SCOPE_QUERY_ROOM_MEMBERSHIP],
)
self.assertEqual(len(service.scopes), 1)
self.assertTrue(service.has_scope(SCOPE_QUERY_ROOM_MEMBERSHIP))

def test_unknown_scope_raises(self) -> None:
with self.assertRaises(ValueError):
ApplicationService(
id="unique_identifier",
sender=UserID.from_string("@as:test"),
token="some_token",
scopes=["does:not:exist"],
)
Loading
Loading