Merge commit '01af446be7cbd63b38f2fe35c2c9a25fac4fdef8' as 'modules/restricted-guests/synapse'

This commit is contained in:
Andrew Ferrazzutti
2025-02-04 09:21:06 -05:00
parent c37b2daf5f
commit 386073c0e8
20 changed files with 1330 additions and 0 deletions
@@ -0,0 +1,3 @@
# ignore all files except the build folder
*
!/synapse_guest_module/*.py
@@ -0,0 +1,45 @@
# @nordeck/synapse-guest-module
## 2.0.0
### Major Changes
- 5539790: Guest users are now only allowed in Ask to Join rooms
## 1.0.0
### Major Changes
- a05ce45: First stable release.
## 0.3.1
### Patch Changes
- 344b7bf: Update the repository name
## 0.3.0
### Minor Changes
- 27680cd: Provide a dedicated registration endpoint to not interfere with the original
registration endpoint.
- 997f47b: Deactivate guest users after a configured expiration time.
## 0.2.0
### Minor Changes
- 00c92e0: Don't include guest users in the search results.
## 0.1.0
### Minor Changes
- b43b066: Disable certain homeserver-wide actions (create room, invite user) for guest users.
## 0.0.1
### Patch Changes
- Add initial version.
@@ -0,0 +1,6 @@
FROM debian:bookworm-slim
WORKDIR /src
ADD synapse_guest_module /src/synapse_guest_module
CMD ["cp", "-r", "/src/synapse_guest_module", "/modules"]
@@ -0,0 +1,93 @@
# Synapse Guest Module
A [pluggable synapse module](https://matrix-org.github.io/synapse/latest/modules/index.html) to restrict the actions of guests.
**Features:**
1. Provides an endpoint that creates temporary users with a same pattern (default: `guest-[randomstring]`).
2. The temporary users have a mandatory displayname suffix (default: ` (Guest)`) that they can't remove from their profile.
3. The temporary users are limited in what they can do (examples: create room, invite users).
4. The temporary users won't be returned by the user directory search results.
5. The temporary users are disabled after an expiration timeout (default: `24 hours`).
## Synapse configuration
This modules requires that the homeserver has the following configuration in their `homeserver.yaml`:
```yaml
# Required so Element is able to show the room preview where the user can login.
allow_guest_access: true
```
## Module installation
Copy the `synapse_guest_module` folder into the python modules path.
This can also be achieved by the [`PYTHONPATH` environment variable](https://docs.python.org/3/using/cmdline.html#envvar-PYTHONPATH).
Add module configuration into `modules` section of `homeserver.yaml`:
```yaml
modules:
- module: synapse_guest_module.GuestModule
config: {}
```
## Module configuration
The module provides (optional) configuration options:
- `user_id_prefix` - the prefix of the usernames that are created by this module. Default: `guest-`.
- `display_name_suffix` - the suffix added to the display name of guest users. Default: ` (Guest)`.
- `enable_user_reaper` - if true, the module disables all users that are older than the configured expiration time. Default: `true`.
- `user_expiration_seconds` - the expiration time in seconds when a guest user expires after their creation. Default: `86400` (=24 hours).
Example configuration:
```yaml
modules:
- module: synapse_guest_module.GuestModule
config:
# Use a german suffix
display_name_suffix: ' (Gast)'
```
## Production installation
The module is not published to a python registry, but we provide a docker container that can be used as an `initContainer` in Kubernetes:
```diff
apiVersion: apps/v1
kind: "StatefulSet"
metadata:
name: synapse
spec:
# ...
template:
spec:
+ # The init container copies the module to he `synapse-modules` volume
+ initContainers:
+ - image: ghcr.io/nordeck/synapse-guest-module:<version>
+ name: install-guest-module
+ volumeMounts:
+ - mountPath: /modules
+ name: synapse-modules
containers:
- name: "synapse"
image: "matrixdotorg/synapse:v1.87.0"
+ env:
+ # Tell python to read the modules from the `/modules` directory
+ - name: PYTHONPATH
+ value: /modules
+ volumeMounts:
+ # Mount the `synapse-modules` volume
+ - mountPath: /modules
+ name: synapse-modules
# ...
+ # Use a local volume to store the module
+ volumes:
+ - emptyDir:
+ medium: Memory
+ sizeLimit: 50Mi
+ name: synapse-modules
# ...
```
@@ -0,0 +1,2 @@
[mypy]
strict = true
@@ -0,0 +1,21 @@
{
"name": "@nordeck/synapse-guest-module",
"version": "2.0.0",
"private": true,
"description": "A synapse module to restrict the actions of guests",
"author": "Nordeck IT + Consulting GmbH",
"license": "Apache-2.0",
"scripts": {
"clean": "echo \"Nothing to clean\"",
"build": "echo \"Nothing to build\"",
"docker:build": "docker build -t nordeck/synapse-guest-module -f Dockerfile .",
"tsc": "echo \"Nothing to tsc\"",
"lint": "echo \"Nothing to lint\"",
"types:py": "node ./scripts/run_in_venv.js tox -e check_types",
"lint:py": "node ./scripts/run_in_venv.js tox -e check_codestyle",
"lint:fix": "node ./scripts/run_in_venv.js tox -e fix_codestyle",
"test": "node ./scripts/run_in_venv.js tox -e py",
"depcheck": "echo \"Nothing to check\"",
"package": "yarn docker:build"
}
}
@@ -0,0 +1,10 @@
[build-system]
requires = ["setuptools", "wheel"]
build-backend = "setuptools.build_meta"
[tool.isort]
profile = "black"
known_first_party = [
"synapse_guest_module",
"tests"
]
@@ -0,0 +1,58 @@
/*
* Copyright 2023 Nordeck IT + Consulting GmbH
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
// A script to run a command in a python virtual environment that is stored in
// `<repo-root>/.venv`. If the virtual environment doesn't exist yet, it will
// be created.
//
// Example: $ node ./run_in_venv.js python --version
const fs = require('fs');
const path = require('path');
const child_process = require('child_process');
const cwd = path.resolve(__dirname, '..');
const venvPath = path.resolve(__dirname, '../../../.venv');
const venvRelativeToCwd = path.relative(cwd, venvPath);
function run(command) {
return new Promise((resolve) => {
const proc = child_process.spawn(
`source ${venvRelativeToCwd}/bin/activate && ${command}`,
[],
{ cwd, stdio: 'inherit', shell: true },
);
proc.on('close', (code) => {
console.log('command terminated:', code);
resolve();
});
});
}
async function main() {
if (!fs.existsSync(venvPath) || !fs.lstatSync(venvPath).isDirectory()) {
child_process.execSync(`python3 -m venv ${venvRelativeToCwd}`, { cwd });
await run('pip install tox');
await run('pip install -e ."[dev]"');
}
const command = process.argv.slice(2).join(' ');
await run(command);
}
main();
@@ -0,0 +1,48 @@
[metadata]
name = synapse_guest_module
description = A synapse module to restrict the actions of guests
long_description = file: README.md
long_description_content_type = text/markdown
version = 2.0.0
classifiers =
License :: OSI Approved :: Apache Software License
[options]
packages =
synapse_guest_module
python_requires = >= 3.7
install_requires =
attrs
[options.package_data]
synapse_guest_module = py.typed
[options.extras_require]
dev =
# for tests
matrix-synapse
tox
twisted
aiounittest
# for type checking
mypy == 1.10.0
pydantic == 2.4.2
# for linting
black == 22.3.0
flake8 == 4.0.1
isort == 5.9.3
[flake8]
# see https://pycodestyle.readthedocs.io/en/latest/intro.html#error-codes
# for error codes. The ones we ignore are:
# W503: line break before binary operator
# W504: line break after binary operator
# E203: whitespace before ':' (which is contrary to pep8?)
# E501: Line too long (black enforces this for us)
# (this is a subset of those ignored in Synapse)
ignore=W503,W504,E203,E501
@@ -0,0 +1,17 @@
# Copyright 2023 Nordeck IT + Consulting GmbH
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from synapse_guest_module.guest_module import GuestModule
__all__ = ["GuestModule"]
@@ -0,0 +1,23 @@
# Copyright 2023 Nordeck IT + Consulting GmbH
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import attr
@attr.s(frozen=True, auto_attribs=True)
class GuestModuleConfig:
user_id_prefix: str
display_name_suffix: str
enable_user_reaper: bool
user_expiration_seconds: int
@@ -0,0 +1,177 @@
# Copyright 2023 Nordeck IT + Consulting GmbH
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import logging
from typing import Any, Dict, Literal, Tuple, Union
from synapse.module_api import (
NOT_SPAM,
ModuleApi,
ProfileInfo,
UserProfile,
errors,
run_as_background_process,
)
from synapse.module_api.errors import ConfigError
from synapse.types import UserID
from synapse_guest_module.config import GuestModuleConfig
from synapse_guest_module.guest_registration_servlet import GuestRegistrationServlet
from synapse_guest_module.guest_user_reaper import GuestUserReaper
logger = logging.getLogger("synapse.contrib." + __name__)
class GuestModule:
def __init__(self, config: GuestModuleConfig, api: ModuleApi):
self._api = api
self._config = config
self.registration_servlet = GuestRegistrationServlet(config, api)
self._api.register_web_resource(
"/_synapse/client/register_guest", self.registration_servlet
)
self._api.register_third_party_rules_callbacks(
on_profile_update=self.profile_update
)
self._api.register_spam_checker_callbacks(
user_may_create_room=self.callback_user_may_create_room,
user_may_invite=self.callback_user_may_invite,
user_may_join_room=self.callback_user_may_join_room,
check_username_for_spam=self.callback_check_username_for_spam,
)
# Start the user reaper
self.reaper = GuestUserReaper(api, config)
if config.enable_user_reaper:
run_as_background_process(
"guest_module_reaper_bg_task",
self.reaper.run,
bg_start_span=False,
)
@staticmethod
def parse_config(config: Dict[str, Any]) -> GuestModuleConfig:
"""Parse the module configuration"""
user_id_prefix = config.get("user_id_prefix", "guest-")
if not isinstance(user_id_prefix, str):
raise ConfigError("Config option 'user_id_prefix' must be a string")
display_name_suffix = config.get("display_name_suffix", " (Guest)")
if not isinstance(display_name_suffix, str):
raise ConfigError("Config option 'display_name_suffix' must be a string")
enable_user_reaper = config.get("enable_user_reaper", True)
if not isinstance(enable_user_reaper, bool):
raise ConfigError("Config option 'enable_user_reaper' must be a bool")
user_expiration_seconds = config.get(
"user_expiration_seconds",
24 * 60 * 60,
)
if not isinstance(user_expiration_seconds, int):
raise ConfigError(
"Config option 'user_expiration_seconds' must be a number"
)
return GuestModuleConfig(
user_id_prefix,
display_name_suffix,
enable_user_reaper,
user_expiration_seconds,
)
async def profile_update(
self,
user_id: str,
new_profile: ProfileInfo,
by_admin: bool,
deactivation: bool,
) -> None:
"""Is called whenever a profile is updated. We check that a guest user
always contains the configured suffix (default ` (Guest)`) and add it if
it is missing.
"""
user_is_guest = user_id.startswith("@" + self._config.user_id_prefix)
if user_is_guest:
new_profile_display_name = (
"" if new_profile.display_name is None else new_profile.display_name
)
guest_display_name_not_valid = not new_profile_display_name.endswith(
self._config.display_name_suffix
)
if guest_display_name_not_valid:
user_id_1 = UserID.from_string(user_id)
guest_display_name = (
new_profile_display_name.strip() + self._config.display_name_suffix
)
await self._api.set_displayname(user_id_1, guest_display_name)
async def callback_user_may_create_room(
self,
user_id: str,
) -> bool:
"""Returns whether this user is allowed to create a room. Guest users
should not be able to do that.
"""
user_is_guest = user_id.startswith("@" + self._config.user_id_prefix)
return not user_is_guest
async def callback_user_may_invite(
self,
inviter: str,
invitee: str,
room_id: str,
) -> bool:
"""Returns whether this user is allowed to invite someone into a room.
Guest users should not be able to to that.
"""
user_is_guest = inviter.startswith("@" + self._config.user_id_prefix)
return not user_is_guest
async def callback_user_may_join_room(
self, user_id: str, room_id: str, is_invited: bool
) -> Union[
Literal["NOT_SPAM"], errors.Codes, Tuple[errors.Codes, Dict[str, Any]], bool
]:
"""Returns whether this user is allowed to join a room. Guest users
should only be able to do that if the room is Ask to Join (knock).
"""
user_is_guest = user_id.startswith("@" + self._config.user_id_prefix)
if not user_is_guest or is_invited:
return NOT_SPAM
join_rules_events = await self._api.get_state_events_in_room(
room_id, [("m.room.join_rules", None)]
)
if join_rules_events is None or len(list(join_rules_events)) == 0:
return errors.Codes.BAD_STATE
for event in join_rules_events:
join_rule = event.get("content", {})
is_knock = join_rule.get("join_rule").startswith("knock")
if user_is_guest and is_knock:
return NOT_SPAM
return errors.Codes.FORBIDDEN
async def callback_check_username_for_spam(self, user_profile: UserProfile) -> bool:
"""Returns whether this user should appear in the user directory. Since
we prefer to not invite guests into normal rooms, we hide them here.
"""
user_is_guest = user_profile["user_id"].startswith(
"@" + self._config.user_id_prefix
)
return user_is_guest
@@ -0,0 +1,93 @@
# Copyright 2023 Nordeck IT + Consulting GmbH
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import logging
import secrets
import string
from typing import Any, Dict, Tuple
from synapse.module_api import (
DirectServeJsonResource,
ModuleApi,
parse_json_object_from_request,
)
from twisted.web.server import Request
from synapse_guest_module.config import GuestModuleConfig
logger = logging.getLogger("synapse.contrib." + __name__)
class GuestRegistrationServlet(DirectServeJsonResource):
"""The `POST /_synapse/client/register_guest` endpoints provides an endpoint
to register a new guest user. It requires the `displayname` property and
returns an object that matches the `AccountAuthInfo` of the
`@matrix-org/react-sdk-module-api`.
"""
def __init__(
self,
config: GuestModuleConfig,
api: ModuleApi,
):
super().__init__()
self._api = api
self._config = config
async def _async_render_POST(self, request: Request) -> Tuple[int, Dict[str, Any]]:
"""On POST requests, generate a new username for a guest, check that it
doesn't exist yet, append the suffix to the displayname, create the user,
create a device, and return the session data to the caller.
"""
json_dict = parse_json_object_from_request(request)
displayname = json_dict.get("displayname")
if not isinstance(displayname, str) or len(displayname.strip()) == 0:
return 400, {"msg": "You must provide a 'displayname' as a string"}
# make sure the regex is unique
for _ in range(10):
random_string = "".join(
secrets.choice(string.ascii_lowercase + string.digits)
for _ in range(32)
)
localpart = self._config.user_id_prefix + random_string
# make sure the user-id does not exist yet
if await self._api.check_user_exists(
self._api.get_qualified_user_id(localpart)
):
continue
logger.info("Register guest with user %s", localpart)
user_id = await self._api.register_user(
localpart, displayname.strip() + self._config.display_name_suffix
)
device_id, access_token, _, _ = await self._api.register_device(user_id)
logger.debug("Registered user %s", user_id)
res = {
"userId": user_id,
"deviceId": device_id,
"accessToken": access_token,
"homeserverUrl": self._api.public_baseurl,
}
return 201, res
return 500, {"msg": "Internal error: Could not find a free username"}
@@ -0,0 +1,142 @@
# Copyright 2023 Nordeck IT + Consulting GmbH
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import logging
import time
from typing import List
from synapse.module_api import DatabasePool, LoggingTransaction, ModuleApi
from synapse_guest_module.config import GuestModuleConfig
logger = logging.getLogger("synapse.contrib." + __name__)
class GuestUserReaper:
def __init__(self, api: ModuleApi, config: GuestModuleConfig):
self._api = api
self._config = config
self.reaper_user = f"{config.user_id_prefix}reaper"
async def run(self) -> None:
logger.info("User cleanup job started")
await self._api.sleep(5.0) # Wait for Synapse to start properly
while True:
logger.debug("Run deactivation loop")
try:
await self.deactivate_expired_guest_users()
except Exception as e:
logger.error("Error in the user deactivation: %s", e)
await self._api.sleep(60.0)
async def deactivate_expired_guest_users(self) -> None:
"""Deactivate all users that are older than the specified expiration
interval. This uses the admin API to disable the user.
"""
def get_expired_users(txn: LoggingTransaction) -> List[str]:
sql = """
SELECT name
FROM users
WHERE name != ?
AND name LIKE ?
AND deactivated = 0
AND creation_ts < ?;
"""
# date operations are database-specific (postgres, sqlite, ...)
expire_ts_seconds = int(time.time() - self._config.user_expiration_seconds)
txn.execute(
sql,
(
self._api.get_qualified_user_id(self.reaper_user),
f"@{self._config.user_id_prefix}%:{self._api.server_name}",
expire_ts_seconds,
),
)
expired_users_rows = txn.fetchall()
return [row[0] for row in expired_users_rows]
expired_users: List[str] = await self._api.run_db_interaction(
"guest_module_get_expired_users",
get_expired_users,
)
if len(expired_users) > 0:
logger.info("Deactivate %d users", len(expired_users))
token = await self.get_admin_token()
for user_id in expired_users:
logger.debug("Deactivate user %s", user_id)
url = f"http://localhost:8008/_synapse/admin/v1/deactivate/{user_id}"
try:
await self._api.http_client.post_json_get_json(
uri=url,
post_json={},
headers={"Authorization": ["Bearer {}".format(token)]},
)
except Exception as e:
logger.error('Failed to delete user "%s": %s', user_id, e)
async def get_admin_token(self) -> str:
"""Create a new admin user in synapse so the module can call the admin
api. If no user or login session exists, we create new ones.
"""
def get_access_token_txn(txn: LoggingTransaction) -> str | None:
tokens = DatabasePool.simple_select_onecol_txn(
txn,
table="access_tokens",
keyvalues={
"user_id": self._api.get_qualified_user_id(self.reaper_user),
},
retcol="token",
)
if len(tokens) > 0 and isinstance(tokens[0], str):
return tokens[0]
else:
return None
token: str | None = await self._api.run_db_interaction(
"guest_module_get_access_token",
get_access_token_txn,
)
if token is not None:
return token
if not await self._api.check_user_exists(self.reaper_user):
logger.info(
'Register new administrator user "%s"',
self.reaper_user,
)
await self._api.register_user(self.reaper_user, admin=True)
logger.info('Register new device for administrator user "%s"', self.reaper_user)
_, access_token, _, _ = await self._api.register_device(
self._api.get_qualified_user_id(self.reaper_user)
)
return access_token
@@ -0,0 +1,134 @@
# Copyright 2023 Nordeck IT + Consulting GmbH
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import sqlite3
from asyncio import Future
from typing import Any, Awaitable, Callable, Tuple, TypeVar
from unittest.mock import Mock
from synapse.http.client import SimpleHttpClient
from synapse.module_api import ModuleApi
from synapse_guest_module import GuestModule
RV = TypeVar("RV")
TV = TypeVar("TV")
class SQLiteStore:
"""In-memory SQLite store. We can't just use a run_db_interaction function that opens
its own connection, since we need to use the same connection for all queries in a
test.
"""
def __init__(self) -> None:
self.conn = sqlite3.connect(":memory:")
async def run_db_interaction(
self, desc: str, f: Callable[..., RV], *args: Any, **kwargs: Any
) -> RV:
cur = CursorWrapper(self.conn.cursor())
try:
res = f(cur, *args, **kwargs)
self.conn.commit()
return res
except Exception:
self.conn.rollback()
raise
class CursorWrapper:
"""Wrapper around a SQLite cursor."""
def __init__(self, cursor: sqlite3.Cursor) -> None:
self.cur = cursor
def execute(self, sql: str, args: Any) -> None:
self.cur.execute(sql, args)
@property
def rowcount(self) -> Any:
return self.cur.rowcount
def fetchone(self) -> Any:
return self.cur.fetchone()
def fetchall(self) -> Any:
return self.cur.fetchall()
def __iter__(self) -> Any:
return self.cur.__iter__()
def __next__(self) -> Any:
return self.cur.__next__()
def make_awaitable(result: TV) -> Awaitable[TV]:
"""
Makes an awaitable, suitable for mocking an `async` function.
This uses Futures as they can be awaited multiple times so can be returned
to multiple callers.
This function has been copied directly from Synapse's tests code.
"""
future = Future() # type: ignore
future.set_result(result)
return future
def get_qualified_user_id(username: str) -> str:
return f"@{username}:matrix.local"
async def register_user(localpart: str, admin: bool = False) -> str:
return f"@{localpart}:matrix.local"
def create_module() -> Tuple[GuestModule, Mock, SQLiteStore]:
store = SQLiteStore()
_setup_db(store.conn)
client = Mock(spec=SimpleHttpClient)
client.post_json_get_json.return_value = make_awaitable(None)
# Create a mock based on the ModuleApi spec, but override some mocked functions
# because some capabilities are needed for running the tests.
module_api = Mock(spec=ModuleApi)
module_api.http_client = client
module_api.server_name = "matrix.local"
module_api.public_baseurl = "https://matrix.local:1234/"
module_api.run_db_interaction.side_effect = store.run_db_interaction
module_api.get_qualified_user_id.side_effect = get_qualified_user_id
module_api.check_user_exists.return_value = make_awaitable(False)
module_api.register_user.side_effect = register_user
module_api.register_device.return_value = make_awaitable(
("DEVICEID", "syn_registered_token", None, None)
)
# If necessary, give parse_config some configuration to parse.
config = GuestModule.parse_config(
{
"enable_user_reaper": False,
}
)
module = GuestModule(config, module_api)
return module, module_api, store
def _setup_db(conn: sqlite3.Connection) -> None:
conn.execute("CREATE TABLE access_tokens(user_id text, token text)")
conn.execute(
"CREATE TABLE users(name text, deactivated smallint, creation_ts bigint)"
)
@@ -0,0 +1,202 @@
# Copyright 2023 Nordeck IT + Consulting GmbH
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import aiounittest
from synapse.module_api import ProfileInfo, UserProfile
from synapse.module_api.errors import ConfigError
from synapse.types import UserID
from synapse_guest_module.config import GuestModuleConfig
from synapse_guest_module.guest_module import GuestModule
from tests import create_module
class GuestModuleTest(aiounittest.AsyncTestCase):
async def test_parse_config_empty(self) -> None:
config = GuestModule.parse_config({})
self.assertEqual(
config,
GuestModuleConfig(
user_id_prefix="guest-",
display_name_suffix=" (Guest)",
enable_user_reaper=True,
user_expiration_seconds=24 * 60 * 60,
),
)
async def test_parse_config_custom(self) -> None:
config = GuestModule.parse_config(
{
"user_id_prefix": "tmp-",
"display_name_suffix": " (Temporary)",
"enable_user_reaper": False,
"user_expiration_seconds": 100,
}
)
self.assertEqual(
config,
GuestModuleConfig(
user_id_prefix="tmp-",
display_name_suffix=" (Temporary)",
enable_user_reaper=False,
user_expiration_seconds=100,
),
)
async def test_parse_config_fail_user_id_prefix(self) -> None:
with self.assertRaisesRegex(
ConfigError, "Config option 'user_id_prefix' must be a string"
):
GuestModule.parse_config(
{
"user_id_prefix": 1234,
}
)
async def test_parse_config_fail_display_name_suffix(self) -> None:
with self.assertRaisesRegex(
ConfigError, "Config option 'display_name_suffix' must be a string"
):
GuestModule.parse_config(
{
"display_name_suffix": 1234,
}
)
async def test_parse_config_fail_enable_user_reaper(self) -> None:
with self.assertRaisesRegex(
ConfigError, "Config option 'enable_user_reaper' must be a bool"
):
GuestModule.parse_config(
{
"enable_user_reaper": "False",
}
)
async def test_parse_config_fail_user_expiration_seconds(self) -> None:
with self.assertRaisesRegex(
ConfigError, "Config option 'user_expiration_seconds' must be a number"
):
GuestModule.parse_config(
{
"user_expiration_seconds": "1",
}
)
async def test_profile_update_no_guest(self) -> None:
module, module_api, _ = create_module()
await module.profile_update(
"@my-user:matrix.local",
ProfileInfo(display_name="My User", avatar_url=None),
True,
False,
)
module_api.set_displayname.assert_not_called()
async def test_profile_update_guest_keep(self) -> None:
module, module_api, _ = create_module()
await module.profile_update(
"@guest-asdf:matrix.local",
ProfileInfo(display_name="My User (Guest)", avatar_url=None),
True,
False,
)
module_api.set_displayname.assert_not_called()
async def test_profile_update_guest_add_and_trim(self) -> None:
module, module_api, _ = create_module()
await module.profile_update(
"@guest-asdf:matrix.local",
ProfileInfo(display_name="My User ", avatar_url=None),
True,
False,
)
module_api.set_displayname.assert_awaited_once_with(
UserID.from_string("@guest-asdf:matrix.local"),
"My User (Guest)",
)
async def test_callback_user_may_create_room_no_guest(self) -> None:
module, _, _ = create_module()
allow = await module.callback_user_may_create_room(
"@my-user:matrix.local",
)
self.assertTrue(allow)
async def test_callback_user_may_create_room_guest(self) -> None:
module, _, _ = create_module()
allow = await module.callback_user_may_create_room(
"@guest-asdf:matrix.local",
)
self.assertFalse(allow)
async def test_callback_user_may_invite_no_guest(self) -> None:
module, _, _ = create_module()
allow = await module.callback_user_may_invite(
"@my-user:matrix.local",
"@inviter:matrix.local",
"!room:matrix.local",
)
self.assertTrue(allow)
async def test_callback_user_may_invite_guest(self) -> None:
module, _, _ = create_module()
allow = await module.callback_user_may_invite(
"@guest-asdf:matrix.local",
"@inviter:matrix.local",
"!room:matrix.local",
)
self.assertFalse(allow)
async def test_callback_check_username_for_spam_no_guest(self) -> None:
module, _, _ = create_module()
allow = await module.callback_check_username_for_spam(
UserProfile(
user_id="@my-user:matrix.local",
display_name=None,
avatar_url=None,
),
)
self.assertFalse(allow)
async def test_callback_check_username_for_spam_guest(self) -> None:
module, _, _ = create_module()
allow = await module.callback_check_username_for_spam(
UserProfile(
user_id="@guest-asdf:matrix.local",
display_name=None,
avatar_url=None,
),
)
self.assertTrue(allow)
@@ -0,0 +1,91 @@
# Copyright 2023 Nordeck IT + Consulting GmbH
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import io
from typing import cast
from unittest.mock import ANY
import aiounittest
from twisted.web.server import Request
from twisted.web.test.requesthelper import DummyRequest
from tests import create_module, make_awaitable
class GuestUserReaperTest(aiounittest.AsyncTestCase):
async def test_async_render_POST_missing_displayname(self) -> None:
module, _, _ = create_module()
request = cast(Request, DummyRequest([]))
request.content = io.BytesIO(b"{}")
status, response = await module.registration_servlet._async_render_POST(request)
self.assertEqual(status, 400)
self.assertEqual(
response, {"msg": "You must provide a 'displayname' as a string"}
)
async def test_async_render_POST_empty_displayname(self) -> None:
module, _, _ = create_module()
request = cast(Request, DummyRequest([]))
request.content = io.BytesIO(b'{"displayname":" "}')
status, response = await module.registration_servlet._async_render_POST(request)
self.assertEqual(status, 400)
self.assertEqual(
response, {"msg": "You must provide a 'displayname' as a string"}
)
async def test_async_render_POST_no_free_username(self) -> None:
module, module_api, _ = create_module()
request = cast(Request, DummyRequest([]))
request.content = io.BytesIO(b'{"displayname":"My Name"}')
module_api.check_user_exists.return_value = make_awaitable(True)
status, response = await module.registration_servlet._async_render_POST(request)
self.assertEqual(status, 500)
self.assertEqual(
response, {"msg": "Internal error: Could not find a free username"}
)
self.assertEqual(module_api.check_user_exists.call_count, 10)
async def test_async_render_POST_success(self) -> None:
module, module_api, _ = create_module()
request = cast(Request, DummyRequest([]))
request.content = io.BytesIO(b'{"displayname":"My Name "}')
status, response = await module.registration_servlet._async_render_POST(request)
self.assertEqual(status, 201)
self.assertRegex(response.pop("userId"), r"^@guest-[A-Za-z0-9]+:matrix.local$")
self.assertDictEqual(
response,
{
"accessToken": "syn_registered_token",
"deviceId": "DEVICEID",
"homeserverUrl": "https://matrix.local:1234/",
# "userId" was already checked by self.assertRegex and was removed from the object
},
)
module_api.register_user.assert_called_with(ANY, "My Name (Guest)")
@@ -0,0 +1,128 @@
# Copyright 2023 Nordeck IT + Consulting GmbH
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import time
from unittest.mock import call
import aiounittest
from tests import create_module, make_awaitable
class GuestUserReaperTest(aiounittest.AsyncTestCase):
async def test_get_admin_token_register(self) -> None:
module, module_api, _ = create_module()
token = await module.reaper.get_admin_token()
module_api.check_user_exists.assert_called_with("guest-reaper")
module_api.register_user.assert_called_with("guest-reaper", admin=True)
module_api.register_device.assert_called_with("@guest-reaper:matrix.local")
self.assertEqual(token, "syn_registered_token")
async def test_get_admin_token_create_device(self) -> None:
module, module_api, _ = create_module()
module_api.check_user_exists.return_value = make_awaitable(True)
token = await module.reaper.get_admin_token()
module_api.check_user_exists.assert_called_with("guest-reaper")
module_api.register_user.assert_not_called()
module_api.register_device.assert_called_with("@guest-reaper:matrix.local")
self.assertEqual(token, "syn_registered_token")
async def test_get_admin_token_read_from_db(self) -> None:
module, module_api, store = create_module()
store.conn.execute(
"INSERT INTO access_tokens VALUES ('@guest-reaper:matrix.local', 'syn_db_token')"
)
module_api.check_user_exists.return_value = make_awaitable(True)
token = await module.reaper.get_admin_token()
module_api.check_user_exists.assert_not_called()
module_api.register_user.assert_not_called()
module_api.register_device.assert_not_called()
self.assertEqual(token, "syn_db_token")
async def test_deactivate_expired_guest_users_success(self) -> None:
module, module_api, store = create_module()
now = int(time.time())
store.conn.executemany(
"INSERT INTO users VALUES (?, ?, ?)",
[
["@user-1:matrix.local", 0, 0],
["@guest-reaper:matrix.local", 0, 0],
["@guest-active:matrix.local", 0, now],
["@guest-deactivated:matrix.local", 1, 0],
["@guest-old-1:matrix.local", 0, 0],
["@guest-old-2:matrix.local", 0, 0],
],
)
await module.reaper.deactivate_expired_guest_users()
self.assertEqual(module_api.http_client.post_json_get_json.await_count, 2)
module_api.http_client.post_json_get_json.assert_has_awaits(
[
call(
uri="http://localhost:8008/_synapse/admin/v1/deactivate/@guest-old-1:matrix.local",
post_json={},
headers={"Authorization": ["Bearer syn_registered_token"]},
),
call(
uri="http://localhost:8008/_synapse/admin/v1/deactivate/@guest-old-2:matrix.local",
post_json={},
headers={"Authorization": ["Bearer syn_registered_token"]},
),
]
)
async def test_deactivate_expired_guest_users_with_failure(self) -> None:
module, module_api, store = create_module()
store.conn.executemany(
"INSERT INTO users VALUES (?, ?, ?)",
[
["@guest-old-1:matrix.local", 0, 0],
["@guest-old-2:matrix.local", 0, 0],
],
)
module_api.http_client.post_json_get_json.side_effect = Exception("")
await module.reaper.deactivate_expired_guest_users()
module_api.http_client.post_json_get_json.assert_has_awaits(
[
call(
uri="http://localhost:8008/_synapse/admin/v1/deactivate/@guest-old-1:matrix.local",
post_json={},
headers={"Authorization": ["Bearer syn_registered_token"]},
),
call(
uri="http://localhost:8008/_synapse/admin/v1/deactivate/@guest-old-2:matrix.local",
post_json={},
headers={"Authorization": ["Bearer syn_registered_token"]},
),
]
)
+37
View File
@@ -0,0 +1,37 @@
[tox]
envlist = py, check_codestyle, check_types
# required for PEP 517 (pyproject.toml-style) builds
isolated_build = true
[testenv:py]
extras = dev
commands =
python -m twisted.trial tests
[testenv:check_codestyle]
extras = dev
commands =
flake8 synapse_guest_module tests
black --check --diff synapse_guest_module tests
isort --check-only --diff synapse_guest_module tests
[testenv:fix_codestyle]
extras = dev
commands =
black synapse_guest_module tests
isort synapse_guest_module tests
[testenv:check_types]
extras = dev
commands =
mypy synapse_guest_module tests