Skip to content
This repository has been archived by the owner on Apr 26, 2024. It is now read-only.

Allow server admin to get admin bit in rooms where local user is an admin #8756

Merged
merged 10 commits into from
Dec 18, 2020
1 change: 1 addition & 0 deletions changelog.d/8756.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Add admin API that lets server admins get power in rooms in which local users have power.
20 changes: 19 additions & 1 deletion docs/admin_api/rooms.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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,
Expand All @@ -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"
}
```
2 changes: 2 additions & 0 deletions synapse/rest/admin/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
DeleteRoomRestServlet,
JoinRoomAliasServlet,
ListRoomRestServlet,
MakeRoomAdminRestServlet,
RoomMembersRestServlet,
RoomRestServlet,
ShutdownRoomRestServlet,
Expand Down Expand Up @@ -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):
Expand Down
136 changes: 134 additions & 2 deletions synapse/rest/admin/rooms.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,8 @@
from http import HTTPStatus
from typing import TYPE_CHECKING, List, Optional, 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,
Expand All @@ -37,6 +37,7 @@
if TYPE_CHECKING:
from synapse.server import HomeServer


logger = logging.getLogger(__name__)


Expand Down Expand Up @@ -367,3 +368,134 @@ async def on_POST(
)

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())
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would it make sense to ensure this is a local user? Maybe it doesn't matter.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it's fine to set it as a remote user, a bit weird but 🤷


# 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]
clokep marked this conversation as resolved.
Show resolved Hide resolved

pl_content = power_levels.content
clokep marked this conversation as resolved.
Show resolved Hide resolved
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, {}
138 changes: 138 additions & 0 deletions tests/rest/admin/test_room.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
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

Expand Down Expand Up @@ -1432,6 +1433,143 @@ def test_join_private_room_if_owner(self):
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",
Expand Down