feat(matrix_member): support force join for local users

Requires synapse as homeserver.
This commit is contained in:
Lars Kaiser 2023-03-28 20:02:39 +02:00
parent a46d21e9df
commit 9dbd99a305
No known key found for this signature in database
GPG key ID: BB97304A16BC5DCF
3 changed files with 256 additions and 6 deletions

View file

@ -36,6 +36,7 @@ class AdminApi:
# init subclasses
self.ratelimit = self.__Ratelimit(self)
self.join = self.__Join(self)
# Make API request
def get(self, path: str) -> requests.Response:
@ -118,6 +119,19 @@ class AdminApi:
user_id = AdminApi.url_encode(user_id)
return self.__parent.delete(self.API_PATH.format(user_id=user_id)).json()
class __Join:
API_PATH = "v1/join/{room_id}"
def __init__(self, parent):
self.__parent = parent
def join(self, room_id: str, user_id: str) -> dict:
room_id = AdminApi.url_encode(room_id)
return self.__parent.post(
self.API_PATH.format(room_id=room_id),
json={"user_id": user_id},
).json()
class Exceptions:
class HTTPException(BaseException):

View file

@ -68,8 +68,18 @@ options:
default: false
type: bool
required: false
force_join:
description:
- If state=member, the module force joins the user by calling the
`/_synapse/admin/v1/join/{room-id}` endpoint of synapses admin API.
- Only works for synapse homeservers and if the provided credentials have admin privileges.
- Only works for local users.
default: false
type: bool
required: false
requirements:
- matrix-nio (Python library)
- requests (Python library)
"""
EXAMPLES = """
@ -99,6 +109,10 @@ try:
from ansible_collections.famedly.matrix.plugins.module_utils.matrix import (
AnsibleNioModule,
)
from ansible_collections.famedly.matrix.plugins.module_utils.synapse import (
AdminApi,
Exceptions,
)
from nio import (
RoomGetStateError,
RoomBanError,
@ -176,12 +190,19 @@ async def invite_to_room(client, room_id, user_id, res):
res["invited"].append(user_id)
async def force_join_into_room(admin_client, room_id, user_id, res):
admin_client.join.join(room_id, user_id)
res["changed"] = True
res["joined"].append(user_id)
async def run_module():
module_args = dict(
state=dict(choices=["member", "kicked", "banned"], required=True),
room_id=dict(type="str", required=True),
user_ids=dict(type="list", required=True, elements="str"),
exclusive=dict(type="bool", required=False, default=False),
force_join=dict(type="bool", required=False, default=False),
)
result = dict(
@ -190,6 +211,7 @@ async def run_module():
unbanned=[],
kicked=[],
invited=[],
joined=[],
members=[],
msg="",
)
@ -202,10 +224,13 @@ async def run_module():
action = module.params["state"]
room_id = module.params["room_id"]
user_ids = module.params["user_ids"]
force_join = module.params["force_join"]
# Check for valid parameter combination
if module.params["exclusive"] and action != "member":
await module.fail_json(msg="exclusive=True can only be used with state=member")
if module.params["force_join"] and action != "member":
await module.fail_json(msg="force_join=True can only be used with state=member")
# Handle ansible check mode
if module.check_mode:
@ -215,6 +240,9 @@ async def run_module():
# Create client object
client = module.client
admin_client = AdminApi(
home_server=module.params["hs_url"], access_token=module.params["token"]
)
try:
# Query all room members (invited users count as member, as they _can_ be in the room)
@ -233,7 +261,12 @@ async def run_module():
if action == "member" and user_id not in present_members:
if user_id in banned_members:
await unban_from_room(client, room_id, user_id, result)
await invite_to_room(client, room_id, user_id, result)
if force_join:
await force_join_into_room(
admin_client, room_id, user_id, result
)
else:
await invite_to_room(client, room_id, user_id, result)
elif action == "kicked" and user_id in present_members:
await kick_from_room(client, room_id, user_id, result)
elif action == "kicked" and user_id in banned_members:
@ -242,6 +275,9 @@ async def run_module():
await ban_from_room(client, room_id, user_id, result)
except NioOperationError:
await module.fail_json(**result)
except (Exceptions.HTTPException, Exceptions.MatrixException) as e:
result["msg"] = str(e)
await module.fail_json(**result)
else:
# Handle exclusive mode: get state and make lists of users to be kicked or invited
to_invite = list(filter(lambda m: m not in present_members, user_ids))
@ -249,11 +285,17 @@ async def run_module():
try:
for user_id in to_invite:
await invite_to_room(client, room_id, user_id, result)
if force_join:
await force_join_into_room(admin_client, room_id, user_id, result)
else:
await invite_to_room(client, room_id, user_id, result)
for user_id in to_kick:
await kick_from_room(client, room_id, user_id, result)
except NioOperationError:
await module.fail_json(**result)
except (Exceptions.HTTPException, Exceptions.MatrixException) as e:
result["msg"] = str(e)
await module.fail_json(**result)
# Get all current members from the room
try:

View file

@ -1,5 +1,6 @@
from __future__ import absolute_import, division, print_function, annotations
import json
import types
import pytest
@ -19,6 +20,11 @@ from ansible_collections.famedly.matrix.tests.unit.mock_nio.utils.RoomSimulator
)
from ansible_collections.famedly.matrix.tests.unit.mock_nio.room import failure
from ansible_collections.famedly.matrix.plugins.module_utils import synapse
from ansible_collections.famedly.matrix.tests.unit.mock_synapse.requests.RequestsBase import (
RequestsBase,
)
from ansible_collections.famedly.matrix.tests.unit.utils import (
AnsibleExitJson,
AnsibleFailJson,
@ -71,6 +77,20 @@ class TestAnsibleModuleMatrixMember:
)
monkeypatch.setenv("ROOM_SIMULATOR", simulator.export())
@staticmethod
def patchAdminApiModule(
monkeypatch: MonkeyPatch, target_module, mock_class: type(RequestsBase)
):
# Mock Admin API
for method in RequestsBase.__dict__:
if isinstance(
getattr(mock_class, method),
(types.FunctionType, types.BuiltinFunctionType),
):
monkeypatch.setattr(
synapse.requests, method, getattr(mock_class, method)
)
def test_check_mode(self, monkeypatch):
self.patchAnsibleNioModule(monkeypatch, MatrixNioSuccess)
set_module_args(
@ -82,7 +102,7 @@ class TestAnsibleModuleMatrixMember:
"state": "member",
"user_ids": ["@user1:matrix.example.tld", "@user2:matrix.example.tld"],
},
check_mode=True
check_mode=True,
)
with pytest.raises(AnsibleExitJson) as result:
matrix_member.main()
@ -242,7 +262,7 @@ class TestAnsibleModuleMatrixMember:
"room_id": "!myroomid:matrix.example.tld",
"state": "member",
"exclusive": True,
"user_ids": ["@user2:matrix.example.tld"],
"user_ids": ["@user2:matrix.example.tld", "@user3:matrix.example.tld"],
}
)
with pytest.raises(AnsibleExitJson) as result:
@ -252,9 +272,132 @@ class TestAnsibleModuleMatrixMember:
assert_expression(ansible_result["banned"] == [])
assert_expression(ansible_result["unbanned"] == [])
assert_expression(ansible_result["kicked"] == ["@user1:matrix.example.tld"])
assert_expression(ansible_result["invited"] == [])
assert_expression(ansible_result["invited"] == ["@user3:matrix.example.tld"])
assert_expression(
list(ansible_result["members"]) == ["@user2:matrix.example.tld"]
list(ansible_result["members"])
== ["@user2:matrix.example.tld", "@user3:matrix.example.tld"]
)
def test_force_join(self, monkeypatch):
self.patchAnsibleNioModule(monkeypatch, MatrixNioSuccess)
self.patchAdminApiModule(monkeypatch, matrix_member, RequestsBase)
set_module_args(
{
"hs_url": "matrix.example.tld",
"token": "supersecrettoken",
"room_id": "!myroomid:matrix.example.tld",
"state": "member",
"force_join": True,
"user_ids": ["@user3:matrix.example.tld"],
}
)
response = {
"_synapse/admin/v1/join/%21myroomid%3Amatrix.example.tld": {
"status": 200,
"content": '{"room_id": "!myroomid:matrix.example.tld"}',
}
}
monkeypatch.setenv("REQUESTS_POST_RESPONSE", json.dumps(response))
with pytest.raises(AnsibleExitJson) as result:
matrix_member.main()
ansible_result = result.value.result
print(ansible_result)
assert_expression(ansible_result["changed"] is True)
assert_expression(ansible_result["banned"] == [])
assert_expression(ansible_result["unbanned"] == [])
assert_expression(ansible_result["kicked"] == [])
assert_expression(ansible_result["invited"] == [])
assert_expression(ansible_result["joined"] == ["@user3:matrix.example.tld"])
# TODO: make this assertion work. Needs proper mocking of Admin API that
# calls the mocked nio in order to have the members changed
# assert_expression(
# list(ansible_result["members"])
# == [
# "@user1:matrix.example.tld",
# "@user2:matrix.example.tld",
# "@user3:matrix.example.tld",
# ]
# )
def test_exclusive_force_join(self, monkeypatch):
self.patchAnsibleNioModule(monkeypatch, MatrixNioSuccess)
self.patchAdminApiModule(monkeypatch, matrix_member, RequestsBase)
set_module_args(
{
"hs_url": "matrix.example.tld",
"token": "supersecrettoken",
"room_id": "!myroomid:matrix.example.tld",
"state": "member",
"force_join": True,
"exclusive": True,
"user_ids": ["@user3:matrix.example.tld"],
}
)
response = {
"_synapse/admin/v1/join/%21myroomid%3Amatrix.example.tld": {
"status": 200,
"content": '{"room_id": "!myroomid:matrix.example.tld"}',
}
}
monkeypatch.setenv("REQUESTS_POST_RESPONSE", json.dumps(response))
with pytest.raises(AnsibleExitJson) as result:
matrix_member.main()
ansible_result = result.value.result
print(ansible_result)
assert_expression(ansible_result["changed"] is True)
assert_expression(ansible_result["banned"] == [])
assert_expression(ansible_result["unbanned"] == [])
assert_expression(
ansible_result["kicked"]
== ["@user1:matrix.example.tld", "@user2:matrix.example.tld"]
)
assert_expression(ansible_result["invited"] == [])
assert_expression(ansible_result["joined"] == ["@user3:matrix.example.tld"])
# TODO: make this assertion work. Needs proper mocking of Admin API that
# calls the mocked nio in order to have the members changed
# assert_expression(
# list(ansible_result["members"])
# == [
# "@user3:matrix.example.tld",
# ]
# )
def test_exclusive_kick_fail(self, monkeypatch):
self.patchAnsibleNioModule(monkeypatch, MatrixNioSuccess)
set_module_args(
{
"hs_url": "matrix.example.tld",
"token": "supersecrettoken",
"room_id": "!myroomid:matrix.example.tld",
"state": "kicked",
"user_ids": ["@user1:matrix.example.tld"],
"exclusive": True,
},
)
with pytest.raises(AnsibleFailJson) as result:
matrix_member.main()
assert_expression(
"exclusive=True can only be used with state=member"
in result.value.result["msg"]
)
def test_force_join_kick_fail(self, monkeypatch):
self.patchAnsibleNioModule(monkeypatch, MatrixNioSuccess)
set_module_args(
{
"hs_url": "matrix.example.tld",
"token": "supersecrettoken",
"room_id": "!myroomid:matrix.example.tld",
"state": "kicked",
"user_ids": ["@user1:matrix.example.tld"],
"force_join": True,
},
)
with pytest.raises(AnsibleFailJson) as result:
matrix_member.main()
assert_expression(
"force_join=True can only be used with state=member"
in result.value.result["msg"]
)
def test_add_user_fail(self, monkeypatch):
@ -350,3 +493,54 @@ class TestAnsibleModuleMatrixMember:
)
with pytest.raises(AnsibleFailJson) as result:
matrix_member.main()
def test_force_join_fail_privileges(self, monkeypatch):
self.patchAnsibleNioModule(monkeypatch, MatrixNioSuccess)
self.patchAdminApiModule(monkeypatch, matrix_member, RequestsBase)
set_module_args(
{
"hs_url": "matrix.example.tld",
"token": "notaserveradmintoken",
"room_id": "!myroomid:matrix.example.tld",
"state": "member",
"force_join": True,
"user_ids": ["@user3:matrix.example.tld"],
}
)
response = {
"_synapse/admin/v1/join/%21myroomid%3Amatrix.example.tld": {
"status": 403,
"content": '{"errcode": "M_FORBIDDEN", "error": "You are not a server admin"}',
}
}
monkeypatch.setenv("REQUESTS_POST_RESPONSE", json.dumps(response))
with pytest.raises(AnsibleFailJson) as result:
matrix_member.main()
print(result.value.result["msg"])
assert_expression("M_FORBIDDEN" in result.value.result["msg"])
def test_exclusive_force_join_fail_privileges(self, monkeypatch):
self.patchAnsibleNioModule(monkeypatch, MatrixNioSuccess)
self.patchAdminApiModule(monkeypatch, matrix_member, RequestsBase)
set_module_args(
{
"hs_url": "matrix.example.tld",
"token": "notaserveradmintoken",
"room_id": "!myroomid:matrix.example.tld",
"state": "member",
"force_join": True,
"exclusive": True,
"user_ids": ["@user3:matrix.example.tld"],
}
)
response = {
"_synapse/admin/v1/join/%21myroomid%3Amatrix.example.tld": {
"status": 403,
"content": '{"errcode": "M_FORBIDDEN", "error": "You are not a server admin"}',
}
}
monkeypatch.setenv("REQUESTS_POST_RESPONSE", json.dumps(response))
with pytest.raises(AnsibleFailJson) as result:
matrix_member.main()
print(result.value.result["msg"])
assert_expression("M_FORBIDDEN" in result.value.result["msg"])