Merge commit '4218473f9' into anoa/dinsic_release_1_31_0
This commit is contained in:
1
changelog.d/8756.feature
Normal file
1
changelog.d/8756.feature
Normal file
@@ -0,0 +1 @@
|
||||
Add admin API that lets server admins get power in rooms in which local users have power.
|
||||
1
changelog.d/8930.feature
Normal file
1
changelog.d/8930.feature
Normal file
@@ -0,0 +1 @@
|
||||
Add an `email.invite_client_location` configuration option to send a web client location to the invite endpoint on the identity server which allows customisation of the email template.
|
||||
1
changelog.d/8958.misc
Normal file
1
changelog.d/8958.misc
Normal file
@@ -0,0 +1 @@
|
||||
Properly store the mapping of external ID to Matrix ID for CAS users.
|
||||
1
changelog.d/8971.bugfix
Normal file
1
changelog.d/8971.bugfix
Normal file
@@ -0,0 +1 @@
|
||||
Fix small bug in v2 state resolution algorithm, which could also cause performance issues for rooms with large numbers of power levels.
|
||||
@@ -8,6 +8,7 @@
|
||||
* [Parameters](#parameters-1)
|
||||
* [Response](#response)
|
||||
* [Undoing room shutdowns](#undoing-room-shutdowns)
|
||||
- [Make Room Admin API](#make-room-admin-api)
|
||||
|
||||
# List Room API
|
||||
|
||||
@@ -467,6 +468,7 @@ The following fields are returned in the JSON response body:
|
||||
the old room to the new.
|
||||
* `new_room_id` - A string representing the room ID of the new room.
|
||||
|
||||
|
||||
## Undoing room shutdowns
|
||||
|
||||
*Note*: This guide may be outdated by the time you read it. By nature of room shutdowns being performed at the database level,
|
||||
@@ -492,4 +494,20 @@ You will have to manually handle, if you so choose, the following:
|
||||
|
||||
* Aliases that would have been redirected to the Content Violation room.
|
||||
* Users that would have been booted from the room (and will have been force-joined to the Content Violation room).
|
||||
* Removal of the Content Violation room if desired.
|
||||
* Removal of the Content Violation room if desired.
|
||||
|
||||
|
||||
# Make Room Admin API
|
||||
|
||||
Grants another user the highest power available to a local user who is in the room.
|
||||
If the user is not in the room, and it is not publicly joinable, then invite the user.
|
||||
|
||||
By default the server admin (the caller) is granted power, but another user can
|
||||
optionally be specified, e.g.:
|
||||
|
||||
```
|
||||
POST /_synapse/admin/v1/rooms/<room_id_or_alias>/make_room_admin
|
||||
{
|
||||
"user_id": "@foo:example.com"
|
||||
}
|
||||
```
|
||||
|
||||
@@ -31,7 +31,7 @@ easy to run CAS implementation built on top of Django.
|
||||
You should now have a Django project configured to serve CAS authentication with
|
||||
a single user created.
|
||||
|
||||
## Configure Synapse (and Riot) to use CAS
|
||||
## Configure Synapse (and Element) to use CAS
|
||||
|
||||
1. Modify your `homeserver.yaml` to enable CAS and point it to your locally
|
||||
running Django test server:
|
||||
@@ -51,9 +51,9 @@ and that the CAS server is on port 8000, both on localhost.
|
||||
|
||||
## Testing the configuration
|
||||
|
||||
Then in Riot:
|
||||
Then in Element:
|
||||
|
||||
1. Visit the login page with a Riot pointing at your homeserver.
|
||||
1. Visit the login page with a Element pointing at your homeserver.
|
||||
2. Click the Single Sign-On button.
|
||||
3. Login using the credentials created with `createsuperuser`.
|
||||
4. You should be logged in.
|
||||
|
||||
@@ -2329,6 +2329,12 @@ email:
|
||||
#
|
||||
#validation_token_lifetime: 15m
|
||||
|
||||
# The web client location to direct users to during an invite. This is passed
|
||||
# to the identity server as the org.matrix.web_client_location key. Defaults
|
||||
# to unset, giving no guidance to the identity server.
|
||||
#
|
||||
#invite_client_location: https://app.element.io
|
||||
|
||||
# Directory in which Synapse will try to find the template files below.
|
||||
# If not set, or the files named below are not found within the template
|
||||
# directory, default templates from within the Synapse package will be used.
|
||||
|
||||
@@ -322,6 +322,22 @@ class EmailConfig(Config):
|
||||
|
||||
self.email_subjects = EmailSubjectConfig(**subjects)
|
||||
|
||||
# The invite client location should be a HTTP(S) URL or None.
|
||||
self.invite_client_location = email_config.get("invite_client_location") or None
|
||||
if self.invite_client_location:
|
||||
if not isinstance(self.invite_client_location, str):
|
||||
raise ConfigError(
|
||||
"Config option email.invite_client_location must be type str"
|
||||
)
|
||||
if not (
|
||||
self.invite_client_location.startswith("http://")
|
||||
or self.invite_client_location.startswith("https://")
|
||||
):
|
||||
raise ConfigError(
|
||||
"Config option email.invite_client_location must be a http or https URL",
|
||||
path=("email", "invite_client_location"),
|
||||
)
|
||||
|
||||
def generate_config_section(self, config_dir_path, server_name, **kwargs):
|
||||
return (
|
||||
"""\
|
||||
@@ -389,6 +405,12 @@ class EmailConfig(Config):
|
||||
#
|
||||
#validation_token_lifetime: 15m
|
||||
|
||||
# The web client location to direct users to during an invite. This is passed
|
||||
# to the identity server as the org.matrix.web_client_location key. Defaults
|
||||
# to unset, giving no guidance to the identity server.
|
||||
#
|
||||
#invite_client_location: https://app.element.io
|
||||
|
||||
# Directory in which Synapse will try to find the template files below.
|
||||
# If not set, or the files named below are not found within the template
|
||||
# directory, default templates from within the Synapse package will be used.
|
||||
|
||||
@@ -13,13 +13,15 @@
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
import logging
|
||||
import urllib
|
||||
from typing import TYPE_CHECKING, Dict, Optional, Tuple
|
||||
import urllib.parse
|
||||
from typing import TYPE_CHECKING, Dict, Optional
|
||||
from xml.etree import ElementTree as ET
|
||||
|
||||
import attr
|
||||
|
||||
from twisted.web.client import PartialDownloadError
|
||||
|
||||
from synapse.api.errors import Codes, LoginError
|
||||
from synapse.api.errors import HttpResponseException
|
||||
from synapse.http.site import SynapseRequest
|
||||
from synapse.types import UserID, map_username_to_mxid_localpart
|
||||
|
||||
@@ -29,6 +31,26 @@ if TYPE_CHECKING:
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CasError(Exception):
|
||||
"""Used to catch errors when validating the CAS ticket.
|
||||
"""
|
||||
|
||||
def __init__(self, error, error_description=None):
|
||||
self.error = error
|
||||
self.error_description = error_description
|
||||
|
||||
def __str__(self):
|
||||
if self.error_description:
|
||||
return "{}: {}".format(self.error, self.error_description)
|
||||
return self.error
|
||||
|
||||
|
||||
@attr.s(slots=True, frozen=True)
|
||||
class CasResponse:
|
||||
username = attr.ib(type=str)
|
||||
attributes = attr.ib(type=Dict[str, Optional[str]])
|
||||
|
||||
|
||||
class CasHandler:
|
||||
"""
|
||||
Utility class for to handle the response from a CAS SSO service.
|
||||
@@ -50,6 +72,8 @@ class CasHandler:
|
||||
|
||||
self._http_client = hs.get_proxied_http_client()
|
||||
|
||||
self._sso_handler = hs.get_sso_handler()
|
||||
|
||||
def _build_service_param(self, args: Dict[str, str]) -> str:
|
||||
"""
|
||||
Generates a value to use as the "service" parameter when redirecting or
|
||||
@@ -69,14 +93,20 @@ class CasHandler:
|
||||
|
||||
async def _validate_ticket(
|
||||
self, ticket: str, service_args: Dict[str, str]
|
||||
) -> Tuple[str, Optional[str]]:
|
||||
) -> CasResponse:
|
||||
"""
|
||||
Validate a CAS ticket with the server, parse the response, and return the user and display name.
|
||||
Validate a CAS ticket with the server, and return the parsed the response.
|
||||
|
||||
Args:
|
||||
ticket: The CAS ticket from the client.
|
||||
service_args: Additional arguments to include in the service URL.
|
||||
Should be the same as those passed to `get_redirect_url`.
|
||||
|
||||
Raises:
|
||||
CasError: If there's an error parsing the CAS response.
|
||||
|
||||
Returns:
|
||||
The parsed CAS response.
|
||||
"""
|
||||
uri = self._cas_server_url + "/proxyValidate"
|
||||
args = {
|
||||
@@ -89,66 +119,65 @@ class CasHandler:
|
||||
# Twisted raises this error if the connection is closed,
|
||||
# even if that's being used old-http style to signal end-of-data
|
||||
body = pde.response
|
||||
except HttpResponseException as e:
|
||||
description = (
|
||||
(
|
||||
'Authorization server responded with a "{status}" error '
|
||||
"while exchanging the authorization code."
|
||||
).format(status=e.code),
|
||||
)
|
||||
raise CasError("server_error", description) from e
|
||||
|
||||
user, attributes = self._parse_cas_response(body)
|
||||
displayname = attributes.pop(self._cas_displayname_attribute, None)
|
||||
return self._parse_cas_response(body)
|
||||
|
||||
for required_attribute, required_value in self._cas_required_attributes.items():
|
||||
# If required attribute was not in CAS Response - Forbidden
|
||||
if required_attribute not in attributes:
|
||||
raise LoginError(401, "Unauthorized", errcode=Codes.UNAUTHORIZED)
|
||||
|
||||
# Also need to check value
|
||||
if required_value is not None:
|
||||
actual_value = attributes[required_attribute]
|
||||
# If required attribute value does not match expected - Forbidden
|
||||
if required_value != actual_value:
|
||||
raise LoginError(401, "Unauthorized", errcode=Codes.UNAUTHORIZED)
|
||||
|
||||
return user, displayname
|
||||
|
||||
def _parse_cas_response(
|
||||
self, cas_response_body: bytes
|
||||
) -> Tuple[str, Dict[str, Optional[str]]]:
|
||||
def _parse_cas_response(self, cas_response_body: bytes) -> CasResponse:
|
||||
"""
|
||||
Retrieve the user and other parameters from the CAS response.
|
||||
|
||||
Args:
|
||||
cas_response_body: The response from the CAS query.
|
||||
|
||||
Raises:
|
||||
CasError: If there's an error parsing the CAS response.
|
||||
|
||||
Returns:
|
||||
A tuple of the user and a mapping of other attributes.
|
||||
The parsed CAS response.
|
||||
"""
|
||||
|
||||
# Ensure the response is valid.
|
||||
root = ET.fromstring(cas_response_body)
|
||||
if not root.tag.endswith("serviceResponse"):
|
||||
raise CasError(
|
||||
"missing_service_response",
|
||||
"root of CAS response is not serviceResponse",
|
||||
)
|
||||
|
||||
success = root[0].tag.endswith("authenticationSuccess")
|
||||
if not success:
|
||||
raise CasError("unsucessful_response", "Unsuccessful CAS response")
|
||||
|
||||
# Iterate through the nodes and pull out the user and any extra attributes.
|
||||
user = None
|
||||
attributes = {}
|
||||
try:
|
||||
root = ET.fromstring(cas_response_body)
|
||||
if not root.tag.endswith("serviceResponse"):
|
||||
raise Exception("root of CAS response is not serviceResponse")
|
||||
success = root[0].tag.endswith("authenticationSuccess")
|
||||
for child in root[0]:
|
||||
if child.tag.endswith("user"):
|
||||
user = child.text
|
||||
if child.tag.endswith("attributes"):
|
||||
for attribute in child:
|
||||
# ElementTree library expands the namespace in
|
||||
# attribute tags to the full URL of the namespace.
|
||||
# We don't care about namespace here and it will always
|
||||
# be encased in curly braces, so we remove them.
|
||||
tag = attribute.tag
|
||||
if "}" in tag:
|
||||
tag = tag.split("}")[1]
|
||||
attributes[tag] = attribute.text
|
||||
if user is None:
|
||||
raise Exception("CAS response does not contain user")
|
||||
except Exception:
|
||||
logger.exception("Error parsing CAS response")
|
||||
raise LoginError(401, "Invalid CAS response", errcode=Codes.UNAUTHORIZED)
|
||||
if not success:
|
||||
raise LoginError(
|
||||
401, "Unsuccessful CAS response", errcode=Codes.UNAUTHORIZED
|
||||
)
|
||||
return user, attributes
|
||||
for child in root[0]:
|
||||
if child.tag.endswith("user"):
|
||||
user = child.text
|
||||
if child.tag.endswith("attributes"):
|
||||
for attribute in child:
|
||||
# ElementTree library expands the namespace in
|
||||
# attribute tags to the full URL of the namespace.
|
||||
# We don't care about namespace here and it will always
|
||||
# be encased in curly braces, so we remove them.
|
||||
tag = attribute.tag
|
||||
if "}" in tag:
|
||||
tag = tag.split("}")[1]
|
||||
attributes[tag] = attribute.text
|
||||
|
||||
# Ensure a user was found.
|
||||
if user is None:
|
||||
raise CasError("no_user", "CAS response does not contain user")
|
||||
|
||||
return CasResponse(user, attributes)
|
||||
|
||||
def get_redirect_url(self, service_args: Dict[str, str]) -> str:
|
||||
"""
|
||||
@@ -201,7 +230,68 @@ class CasHandler:
|
||||
args["redirectUrl"] = client_redirect_url
|
||||
if session:
|
||||
args["session"] = session
|
||||
username, user_display_name = await self._validate_ticket(ticket, args)
|
||||
|
||||
try:
|
||||
cas_response = await self._validate_ticket(ticket, args)
|
||||
except CasError as e:
|
||||
logger.exception("Could not validate ticket")
|
||||
self._sso_handler.render_error(request, e.error, e.error_description, 401)
|
||||
return
|
||||
|
||||
await self._handle_cas_response(
|
||||
request, cas_response, client_redirect_url, session
|
||||
)
|
||||
|
||||
async def _handle_cas_response(
|
||||
self,
|
||||
request: SynapseRequest,
|
||||
cas_response: CasResponse,
|
||||
client_redirect_url: Optional[str],
|
||||
session: Optional[str],
|
||||
) -> None:
|
||||
"""Handle a CAS response to a ticket request.
|
||||
|
||||
Assumes that the response has been validated. Maps the user onto an MXID,
|
||||
registering them if necessary, and returns a response to the browser.
|
||||
|
||||
Args:
|
||||
request: the incoming request from the browser. We'll respond to it with an
|
||||
HTML page or a redirect
|
||||
|
||||
cas_response: The parsed CAS response.
|
||||
|
||||
client_redirect_url: the redirectUrl parameter from the `/cas/ticket` HTTP request, if given.
|
||||
This should be the same as the redirectUrl from the original `/login/sso/redirect` request.
|
||||
|
||||
session: The session parameter from the `/cas/ticket` HTTP request, if given.
|
||||
This should be the UI Auth session id.
|
||||
"""
|
||||
|
||||
# Ensure that the attributes of the logged in user meet the required
|
||||
# attributes.
|
||||
for required_attribute, required_value in self._cas_required_attributes.items():
|
||||
# If required attribute was not in CAS Response - Forbidden
|
||||
if required_attribute not in cas_response.attributes:
|
||||
self._sso_handler.render_error(
|
||||
request,
|
||||
"unauthorised",
|
||||
"You are not authorised to log in here.",
|
||||
401,
|
||||
)
|
||||
return
|
||||
|
||||
# Also need to check value
|
||||
if required_value is not None:
|
||||
actual_value = cas_response.attributes[required_attribute]
|
||||
# If required attribute value does not match expected - Forbidden
|
||||
if required_value != actual_value:
|
||||
self._sso_handler.render_error(
|
||||
request,
|
||||
"unauthorised",
|
||||
"You are not authorised to log in here.",
|
||||
401,
|
||||
)
|
||||
return
|
||||
|
||||
# Pull out the user-agent and IP from the request.
|
||||
user_agent = request.get_user_agent("")
|
||||
@@ -209,7 +299,7 @@ class CasHandler:
|
||||
|
||||
# Get the matrix ID from the CAS username.
|
||||
user_id = await self._map_cas_user_to_matrix_user(
|
||||
username, user_display_name, user_agent, ip_address
|
||||
cas_response, user_agent, ip_address
|
||||
)
|
||||
|
||||
if session:
|
||||
@@ -225,18 +315,13 @@ class CasHandler:
|
||||
)
|
||||
|
||||
async def _map_cas_user_to_matrix_user(
|
||||
self,
|
||||
remote_user_id: str,
|
||||
display_name: Optional[str],
|
||||
user_agent: str,
|
||||
ip_address: str,
|
||||
self, cas_response: CasResponse, user_agent: str, ip_address: str,
|
||||
) -> str:
|
||||
"""
|
||||
Given a CAS username, retrieve the user ID for it and possibly register the user.
|
||||
|
||||
Args:
|
||||
remote_user_id: The username from the CAS response.
|
||||
display_name: The display name from the CAS response.
|
||||
cas_response: The parsed CAS response.
|
||||
user_agent: The user agent of the client making the request.
|
||||
ip_address: The IP address of the client making the request.
|
||||
|
||||
@@ -244,15 +329,17 @@ class CasHandler:
|
||||
The user ID associated with this response.
|
||||
"""
|
||||
|
||||
localpart = map_username_to_mxid_localpart(remote_user_id)
|
||||
localpart = map_username_to_mxid_localpart(cas_response.username)
|
||||
user_id = UserID(localpart, self._hostname).to_string()
|
||||
registered_user_id = await self._auth_handler.check_user_exists(user_id)
|
||||
|
||||
displayname = cas_response.attributes.get(self._cas_displayname_attribute, None)
|
||||
|
||||
# If the user does not exist, register it.
|
||||
if not registered_user_id:
|
||||
registered_user_id = await self._registration_handler.register_user(
|
||||
localpart=localpart,
|
||||
default_display_name=display_name,
|
||||
default_display_name=displayname,
|
||||
user_agent_ips=[(user_agent, ip_address)],
|
||||
)
|
||||
|
||||
|
||||
@@ -55,13 +55,11 @@ class IdentityHandler(BaseHandler):
|
||||
self.federation_http_client = hs.get_federation_http_client()
|
||||
self.hs = hs
|
||||
|
||||
self.trusted_id_servers = set(hs.config.trusted_third_party_id_servers)
|
||||
self.trust_any_id_server_just_for_testing_do_not_use = (
|
||||
hs.config.use_insecure_ssl_client_just_for_testing_do_not_use
|
||||
)
|
||||
self.rewrite_identity_server_urls = hs.config.rewrite_identity_server_urls
|
||||
self._enable_lookup = hs.config.enable_3pid_lookup
|
||||
|
||||
self._web_client_location = hs.config.invite_client_location
|
||||
|
||||
async def threepid_from_creds(
|
||||
self, id_server_url: str, creds: Dict[str, str]
|
||||
) -> Optional[JsonDict]:
|
||||
@@ -940,6 +938,9 @@ class IdentityHandler(BaseHandler):
|
||||
"sender_display_name": inviter_display_name,
|
||||
"sender_avatar_url": inviter_avatar_url,
|
||||
}
|
||||
# If a custom web client location is available, include it in the request.
|
||||
if self._web_client_location:
|
||||
invite_config["org.matrix.web_client_location"] = self._web_client_location
|
||||
|
||||
# Rewrite the identity server URL if necessary
|
||||
id_server_url = self.rewrite_id_server_url(id_server, add_https=True)
|
||||
|
||||
@@ -101,7 +101,11 @@ class SsoHandler:
|
||||
self._username_mapping_sessions = {} # type: Dict[str, UsernameMappingSession]
|
||||
|
||||
def render_error(
|
||||
self, request, error: str, error_description: Optional[str] = None
|
||||
self,
|
||||
request: Request,
|
||||
error: str,
|
||||
error_description: Optional[str] = None,
|
||||
code: int = 400,
|
||||
) -> None:
|
||||
"""Renders the error template and responds with it.
|
||||
|
||||
@@ -113,11 +117,12 @@ class SsoHandler:
|
||||
We'll respond with an HTML page describing the error.
|
||||
error: A technical identifier for this error.
|
||||
error_description: A human-readable description of the error.
|
||||
code: The integer error code (an HTTP response code)
|
||||
"""
|
||||
html = self._error_template.render(
|
||||
error=error, error_description=error_description
|
||||
)
|
||||
respond_with_html(request, 400, html)
|
||||
respond_with_html(request, code, html)
|
||||
|
||||
async def get_sso_user_by_remote_user_id(
|
||||
self, auth_provider_id: str, remote_user_id: str
|
||||
|
||||
@@ -38,6 +38,7 @@ from synapse.rest.admin.rooms import (
|
||||
DeleteRoomRestServlet,
|
||||
JoinRoomAliasServlet,
|
||||
ListRoomRestServlet,
|
||||
MakeRoomAdminRestServlet,
|
||||
RoomMembersRestServlet,
|
||||
RoomRestServlet,
|
||||
ShutdownRoomRestServlet,
|
||||
@@ -228,6 +229,7 @@ def register_servlets(hs, http_server):
|
||||
EventReportDetailRestServlet(hs).register(http_server)
|
||||
EventReportsRestServlet(hs).register(http_server)
|
||||
PushersRestServlet(hs).register(http_server)
|
||||
MakeRoomAdminRestServlet(hs).register(http_server)
|
||||
|
||||
|
||||
def register_servlets_for_client_rest_resource(hs, http_server):
|
||||
|
||||
@@ -16,8 +16,8 @@ import logging
|
||||
from http import HTTPStatus
|
||||
from typing import TYPE_CHECKING, Tuple
|
||||
|
||||
from synapse.api.constants import EventTypes, JoinRules
|
||||
from synapse.api.errors import Codes, NotFoundError, SynapseError
|
||||
from synapse.api.constants import EventTypes, JoinRules, Membership
|
||||
from synapse.api.errors import AuthError, Codes, NotFoundError, SynapseError
|
||||
from synapse.http.servlet import (
|
||||
RestServlet,
|
||||
assert_params_in_dict,
|
||||
@@ -38,6 +38,7 @@ from synapse.types import JsonDict, RoomAlias, RoomID, UserID, create_requester
|
||||
if TYPE_CHECKING:
|
||||
from synapse.server import HomeServer
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -366,3 +367,134 @@ class JoinRoomAliasServlet(RestServlet):
|
||||
)
|
||||
|
||||
return 200, {"room_id": room_id}
|
||||
|
||||
|
||||
class MakeRoomAdminRestServlet(RestServlet):
|
||||
"""Allows a server admin to get power in a room if a local user has power in
|
||||
a room. Will also invite the user if they're not in the room and it's a
|
||||
private room. Can specify another user (rather than the admin user) to be
|
||||
granted power, e.g.:
|
||||
|
||||
POST/_synapse/admin/v1/rooms/<room_id_or_alias>/make_room_admin
|
||||
{
|
||||
"user_id": "@foo:example.com"
|
||||
}
|
||||
"""
|
||||
|
||||
PATTERNS = admin_patterns("/rooms/(?P<room_identifier>[^/]*)/make_room_admin")
|
||||
|
||||
def __init__(self, hs: "HomeServer"):
|
||||
self.hs = hs
|
||||
self.auth = hs.get_auth()
|
||||
self.room_member_handler = hs.get_room_member_handler()
|
||||
self.event_creation_handler = hs.get_event_creation_handler()
|
||||
self.state_handler = hs.get_state_handler()
|
||||
self.is_mine_id = hs.is_mine_id
|
||||
|
||||
async def on_POST(self, request, room_identifier):
|
||||
requester = await self.auth.get_user_by_req(request)
|
||||
await assert_user_is_admin(self.auth, requester.user)
|
||||
content = parse_json_object_from_request(request, allow_empty_body=True)
|
||||
|
||||
# Resolve to a room ID, if necessary.
|
||||
if RoomID.is_valid(room_identifier):
|
||||
room_id = room_identifier
|
||||
elif RoomAlias.is_valid(room_identifier):
|
||||
room_alias = RoomAlias.from_string(room_identifier)
|
||||
room_id, _ = await self.room_member_handler.lookup_room_alias(room_alias)
|
||||
room_id = room_id.to_string()
|
||||
else:
|
||||
raise SynapseError(
|
||||
400, "%s was not legal room ID or room alias" % (room_identifier,)
|
||||
)
|
||||
|
||||
# Which user to grant room admin rights to.
|
||||
user_to_add = content.get("user_id", requester.user.to_string())
|
||||
|
||||
# Figure out which local users currently have power in the room, if any.
|
||||
room_state = await self.state_handler.get_current_state(room_id)
|
||||
if not room_state:
|
||||
raise SynapseError(400, "Server not in room")
|
||||
|
||||
create_event = room_state[(EventTypes.Create, "")]
|
||||
power_levels = room_state.get((EventTypes.PowerLevels, ""))
|
||||
|
||||
if power_levels is not None:
|
||||
# We pick the local user with the highest power.
|
||||
user_power = power_levels.content.get("users", {})
|
||||
admin_users = [
|
||||
user_id for user_id in user_power if self.is_mine_id(user_id)
|
||||
]
|
||||
admin_users.sort(key=lambda user: user_power[user])
|
||||
|
||||
if not admin_users:
|
||||
raise SynapseError(400, "No local admin user in room")
|
||||
|
||||
admin_user_id = admin_users[-1]
|
||||
|
||||
pl_content = power_levels.content
|
||||
else:
|
||||
# If there is no power level events then the creator has rights.
|
||||
pl_content = {}
|
||||
admin_user_id = create_event.sender
|
||||
if not self.is_mine_id(admin_user_id):
|
||||
raise SynapseError(
|
||||
400, "No local admin user in room",
|
||||
)
|
||||
|
||||
# Grant the user power equal to the room admin by attempting to send an
|
||||
# updated power level event.
|
||||
new_pl_content = dict(pl_content)
|
||||
new_pl_content["users"] = dict(pl_content.get("users", {}))
|
||||
new_pl_content["users"][user_to_add] = new_pl_content["users"][admin_user_id]
|
||||
|
||||
fake_requester = create_requester(
|
||||
admin_user_id, authenticated_entity=requester.authenticated_entity,
|
||||
)
|
||||
|
||||
try:
|
||||
await self.event_creation_handler.create_and_send_nonmember_event(
|
||||
fake_requester,
|
||||
event_dict={
|
||||
"content": new_pl_content,
|
||||
"sender": admin_user_id,
|
||||
"type": EventTypes.PowerLevels,
|
||||
"state_key": "",
|
||||
"room_id": room_id,
|
||||
},
|
||||
)
|
||||
except AuthError:
|
||||
# The admin user we found turned out not to have enough power.
|
||||
raise SynapseError(
|
||||
400, "No local admin user in room with power to update power levels."
|
||||
)
|
||||
|
||||
# Now we check if the user we're granting admin rights to is already in
|
||||
# the room. If not and it's not a public room we invite them.
|
||||
member_event = room_state.get((EventTypes.Member, user_to_add))
|
||||
is_joined = False
|
||||
if member_event:
|
||||
is_joined = member_event.content["membership"] in (
|
||||
Membership.JOIN,
|
||||
Membership.INVITE,
|
||||
)
|
||||
|
||||
if is_joined:
|
||||
return 200, {}
|
||||
|
||||
join_rules = room_state.get((EventTypes.JoinRules, ""))
|
||||
is_public = False
|
||||
if join_rules:
|
||||
is_public = join_rules.content.get("join_rule") == JoinRules.PUBLIC
|
||||
|
||||
if is_public:
|
||||
return 200, {}
|
||||
|
||||
await self.room_member_handler.update_membership(
|
||||
fake_requester,
|
||||
target=UserID.from_string(user_to_add),
|
||||
room_id=room_id,
|
||||
action=Membership.INVITE,
|
||||
)
|
||||
|
||||
return 200, {}
|
||||
|
||||
@@ -658,7 +658,7 @@ async def _get_mainline_depth_for_event(
|
||||
# We do an iterative search, replacing `event with the power level in its
|
||||
# auth events (if any)
|
||||
while tmp_event:
|
||||
depth = mainline_map.get(event.event_id)
|
||||
depth = mainline_map.get(tmp_event.event_id)
|
||||
if depth is not None:
|
||||
return depth
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ from typing import List, Optional
|
||||
from mock import Mock
|
||||
|
||||
import synapse.rest.admin
|
||||
from synapse.api.constants import EventTypes, Membership
|
||||
from synapse.api.errors import Codes
|
||||
from synapse.rest.client.v1 import directory, events, login, room
|
||||
|
||||
@@ -1432,6 +1433,143 @@ class JoinAliasRoomTestCase(unittest.HomeserverTestCase):
|
||||
self.assertEqual(private_room_id, channel.json_body["joined_rooms"][0])
|
||||
|
||||
|
||||
class MakeRoomAdminTestCase(unittest.HomeserverTestCase):
|
||||
servlets = [
|
||||
synapse.rest.admin.register_servlets,
|
||||
room.register_servlets,
|
||||
login.register_servlets,
|
||||
]
|
||||
|
||||
def prepare(self, reactor, clock, homeserver):
|
||||
self.admin_user = self.register_user("admin", "pass", admin=True)
|
||||
self.admin_user_tok = self.login("admin", "pass")
|
||||
|
||||
self.creator = self.register_user("creator", "test")
|
||||
self.creator_tok = self.login("creator", "test")
|
||||
|
||||
self.second_user_id = self.register_user("second", "test")
|
||||
self.second_tok = self.login("second", "test")
|
||||
|
||||
self.public_room_id = self.helper.create_room_as(
|
||||
self.creator, tok=self.creator_tok, is_public=True
|
||||
)
|
||||
self.url = "/_synapse/admin/v1/rooms/{}/make_room_admin".format(
|
||||
self.public_room_id
|
||||
)
|
||||
|
||||
def test_public_room(self):
|
||||
"""Test that getting admin in a public room works.
|
||||
"""
|
||||
room_id = self.helper.create_room_as(
|
||||
self.creator, tok=self.creator_tok, is_public=True
|
||||
)
|
||||
|
||||
channel = self.make_request(
|
||||
"POST",
|
||||
"/_synapse/admin/v1/rooms/{}/make_room_admin".format(room_id),
|
||||
content={},
|
||||
access_token=self.admin_user_tok,
|
||||
)
|
||||
|
||||
self.assertEqual(200, int(channel.result["code"]), msg=channel.result["body"])
|
||||
|
||||
# Now we test that we can join the room and ban a user.
|
||||
self.helper.join(room_id, self.admin_user, tok=self.admin_user_tok)
|
||||
self.helper.change_membership(
|
||||
room_id,
|
||||
self.admin_user,
|
||||
"@test:test",
|
||||
Membership.BAN,
|
||||
tok=self.admin_user_tok,
|
||||
)
|
||||
|
||||
def test_private_room(self):
|
||||
"""Test that getting admin in a private room works and we get invited.
|
||||
"""
|
||||
room_id = self.helper.create_room_as(
|
||||
self.creator, tok=self.creator_tok, is_public=False,
|
||||
)
|
||||
|
||||
channel = self.make_request(
|
||||
"POST",
|
||||
"/_synapse/admin/v1/rooms/{}/make_room_admin".format(room_id),
|
||||
content={},
|
||||
access_token=self.admin_user_tok,
|
||||
)
|
||||
|
||||
self.assertEqual(200, int(channel.result["code"]), msg=channel.result["body"])
|
||||
|
||||
# Now we test that we can join the room (we should have received an
|
||||
# invite) and can ban a user.
|
||||
self.helper.join(room_id, self.admin_user, tok=self.admin_user_tok)
|
||||
self.helper.change_membership(
|
||||
room_id,
|
||||
self.admin_user,
|
||||
"@test:test",
|
||||
Membership.BAN,
|
||||
tok=self.admin_user_tok,
|
||||
)
|
||||
|
||||
def test_other_user(self):
|
||||
"""Test that giving admin in a public room works to a non-admin user works.
|
||||
"""
|
||||
room_id = self.helper.create_room_as(
|
||||
self.creator, tok=self.creator_tok, is_public=True
|
||||
)
|
||||
|
||||
channel = self.make_request(
|
||||
"POST",
|
||||
"/_synapse/admin/v1/rooms/{}/make_room_admin".format(room_id),
|
||||
content={"user_id": self.second_user_id},
|
||||
access_token=self.admin_user_tok,
|
||||
)
|
||||
|
||||
self.assertEqual(200, int(channel.result["code"]), msg=channel.result["body"])
|
||||
|
||||
# Now we test that we can join the room and ban a user.
|
||||
self.helper.join(room_id, self.second_user_id, tok=self.second_tok)
|
||||
self.helper.change_membership(
|
||||
room_id,
|
||||
self.second_user_id,
|
||||
"@test:test",
|
||||
Membership.BAN,
|
||||
tok=self.second_tok,
|
||||
)
|
||||
|
||||
def test_not_enough_power(self):
|
||||
"""Test that we get a sensible error if there are no local room admins.
|
||||
"""
|
||||
room_id = self.helper.create_room_as(
|
||||
self.creator, tok=self.creator_tok, is_public=True
|
||||
)
|
||||
|
||||
# The creator drops admin rights in the room.
|
||||
pl = self.helper.get_state(
|
||||
room_id, EventTypes.PowerLevels, tok=self.creator_tok
|
||||
)
|
||||
pl["users"][self.creator] = 0
|
||||
self.helper.send_state(
|
||||
room_id, EventTypes.PowerLevels, body=pl, tok=self.creator_tok
|
||||
)
|
||||
|
||||
channel = self.make_request(
|
||||
"POST",
|
||||
"/_synapse/admin/v1/rooms/{}/make_room_admin".format(room_id),
|
||||
content={},
|
||||
access_token=self.admin_user_tok,
|
||||
)
|
||||
|
||||
# We expect this to fail with a 400 as there are no room admins.
|
||||
#
|
||||
# (Note we assert the error message to ensure that it's not denied for
|
||||
# some other reason)
|
||||
self.assertEqual(400, int(channel.result["code"]), msg=channel.result["body"])
|
||||
self.assertEqual(
|
||||
channel.json_body["error"],
|
||||
"No local admin user in room with power to update power levels.",
|
||||
)
|
||||
|
||||
|
||||
PURGE_TABLES = [
|
||||
"current_state_events",
|
||||
"event_backward_extremities",
|
||||
|
||||
@@ -88,7 +88,7 @@ class FakeEvent:
|
||||
event_dict = {
|
||||
"auth_events": [(a, {}) for a in auth_events],
|
||||
"prev_events": [(p, {}) for p in prev_events],
|
||||
"event_id": self.node_id,
|
||||
"event_id": self.event_id,
|
||||
"sender": self.sender,
|
||||
"type": self.type,
|
||||
"content": self.content,
|
||||
@@ -381,6 +381,61 @@ class StateTestCase(unittest.TestCase):
|
||||
|
||||
self.do_check(events, edges, expected_state_ids)
|
||||
|
||||
def test_mainline_sort(self):
|
||||
"""Tests that the mainline ordering works correctly.
|
||||
"""
|
||||
|
||||
events = [
|
||||
FakeEvent(
|
||||
id="T1", sender=ALICE, type=EventTypes.Topic, state_key="", content={}
|
||||
),
|
||||
FakeEvent(
|
||||
id="PA1",
|
||||
sender=ALICE,
|
||||
type=EventTypes.PowerLevels,
|
||||
state_key="",
|
||||
content={"users": {ALICE: 100, BOB: 50}},
|
||||
),
|
||||
FakeEvent(
|
||||
id="T2", sender=ALICE, type=EventTypes.Topic, state_key="", content={}
|
||||
),
|
||||
FakeEvent(
|
||||
id="PA2",
|
||||
sender=ALICE,
|
||||
type=EventTypes.PowerLevels,
|
||||
state_key="",
|
||||
content={
|
||||
"users": {ALICE: 100, BOB: 50},
|
||||
"events": {EventTypes.PowerLevels: 100},
|
||||
},
|
||||
),
|
||||
FakeEvent(
|
||||
id="PB",
|
||||
sender=BOB,
|
||||
type=EventTypes.PowerLevels,
|
||||
state_key="",
|
||||
content={"users": {ALICE: 100, BOB: 50}},
|
||||
),
|
||||
FakeEvent(
|
||||
id="T3", sender=BOB, type=EventTypes.Topic, state_key="", content={}
|
||||
),
|
||||
FakeEvent(
|
||||
id="T4", sender=ALICE, type=EventTypes.Topic, state_key="", content={}
|
||||
),
|
||||
]
|
||||
|
||||
edges = [
|
||||
["END", "T3", "PA2", "T2", "PA1", "T1", "START"],
|
||||
["END", "T4", "PB", "PA1"],
|
||||
]
|
||||
|
||||
# We expect T3 to be picked as the other topics are pointing at older
|
||||
# power levels. Note that without mainline ordering we'd pick T4 due to
|
||||
# it being sent *after* T3.
|
||||
expected_state_ids = ["T3", "PA2"]
|
||||
|
||||
self.do_check(events, edges, expected_state_ids)
|
||||
|
||||
def do_check(self, events, edges, expected_state_ids):
|
||||
"""Take a list of events and edges and calculate the state of the
|
||||
graph at END, and asserts it matches `expected_state_ids`
|
||||
|
||||
Reference in New Issue
Block a user