forked from MirrorHub/synapse
Allow server admin to get admin bit in rooms where local user is an admin (#8756)
This adds an admin API that 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 its a private room. Can specify another user (rather than the admin user) to be granted power. Co-authored-by: Matthew Hodgson <matthew@matrix.org>
This commit is contained in:
parent
5e7d75daa2
commit
d781a81e69
5 changed files with 294 additions and 3 deletions
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.
|
|
@ -8,6 +8,7 @@
|
||||||
* [Parameters](#parameters-1)
|
* [Parameters](#parameters-1)
|
||||||
* [Response](#response)
|
* [Response](#response)
|
||||||
* [Undoing room shutdowns](#undoing-room-shutdowns)
|
* [Undoing room shutdowns](#undoing-room-shutdowns)
|
||||||
|
- [Make Room Admin API](#make-room-admin-api)
|
||||||
|
|
||||||
# List Room API
|
# List Room API
|
||||||
|
|
||||||
|
@ -467,6 +468,7 @@ The following fields are returned in the JSON response body:
|
||||||
the old room to the new.
|
the old room to the new.
|
||||||
* `new_room_id` - A string representing the room ID of the new room.
|
* `new_room_id` - A string representing the room ID of the new room.
|
||||||
|
|
||||||
|
|
||||||
## Undoing room shutdowns
|
## 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,
|
*Note*: This guide may be outdated by the time you read it. By nature of room shutdowns being performed at the database level,
|
||||||
|
@ -493,3 +495,19 @@ You will have to manually handle, if you so choose, the following:
|
||||||
* Aliases that would have been redirected to the Content Violation room.
|
* 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).
|
* 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"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
|
@ -38,6 +38,7 @@ from synapse.rest.admin.rooms import (
|
||||||
DeleteRoomRestServlet,
|
DeleteRoomRestServlet,
|
||||||
JoinRoomAliasServlet,
|
JoinRoomAliasServlet,
|
||||||
ListRoomRestServlet,
|
ListRoomRestServlet,
|
||||||
|
MakeRoomAdminRestServlet,
|
||||||
RoomMembersRestServlet,
|
RoomMembersRestServlet,
|
||||||
RoomRestServlet,
|
RoomRestServlet,
|
||||||
ShutdownRoomRestServlet,
|
ShutdownRoomRestServlet,
|
||||||
|
@ -228,6 +229,7 @@ def register_servlets(hs, http_server):
|
||||||
EventReportDetailRestServlet(hs).register(http_server)
|
EventReportDetailRestServlet(hs).register(http_server)
|
||||||
EventReportsRestServlet(hs).register(http_server)
|
EventReportsRestServlet(hs).register(http_server)
|
||||||
PushersRestServlet(hs).register(http_server)
|
PushersRestServlet(hs).register(http_server)
|
||||||
|
MakeRoomAdminRestServlet(hs).register(http_server)
|
||||||
|
|
||||||
|
|
||||||
def register_servlets_for_client_rest_resource(hs, http_server):
|
def register_servlets_for_client_rest_resource(hs, http_server):
|
||||||
|
|
|
@ -16,8 +16,8 @@ import logging
|
||||||
from http import HTTPStatus
|
from http import HTTPStatus
|
||||||
from typing import TYPE_CHECKING, List, Optional, Tuple
|
from typing import TYPE_CHECKING, List, Optional, Tuple
|
||||||
|
|
||||||
from synapse.api.constants import EventTypes, JoinRules
|
from synapse.api.constants import EventTypes, JoinRules, Membership
|
||||||
from synapse.api.errors import Codes, NotFoundError, SynapseError
|
from synapse.api.errors import AuthError, Codes, NotFoundError, SynapseError
|
||||||
from synapse.http.servlet import (
|
from synapse.http.servlet import (
|
||||||
RestServlet,
|
RestServlet,
|
||||||
assert_params_in_dict,
|
assert_params_in_dict,
|
||||||
|
@ -37,6 +37,7 @@ from synapse.types import JsonDict, RoomAlias, RoomID, UserID, create_requester
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from synapse.server import HomeServer
|
from synapse.server import HomeServer
|
||||||
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@ -367,3 +368,134 @@ class JoinRoomAliasServlet(RestServlet):
|
||||||
)
|
)
|
||||||
|
|
||||||
return 200, {"room_id": room_id}
|
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, {}
|
||||||
|
|
|
@ -20,6 +20,7 @@ from typing import List, Optional
|
||||||
from mock import Mock
|
from mock import Mock
|
||||||
|
|
||||||
import synapse.rest.admin
|
import synapse.rest.admin
|
||||||
|
from synapse.api.constants import EventTypes, Membership
|
||||||
from synapse.api.errors import Codes
|
from synapse.api.errors import Codes
|
||||||
from synapse.rest.client.v1 import directory, events, login, room
|
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])
|
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 = [
|
PURGE_TABLES = [
|
||||||
"current_state_events",
|
"current_state_events",
|
||||||
"event_backward_extremities",
|
"event_backward_extremities",
|
||||||
|
|
Loading…
Reference in a new issue