diff --git a/modules/restricted-guests/synapse/.dockerignore b/modules/restricted-guests/synapse/.dockerignore new file mode 100644 index 0000000000..5b69719716 --- /dev/null +++ b/modules/restricted-guests/synapse/.dockerignore @@ -0,0 +1,3 @@ +# ignore all files except the build folder +* +!/synapse_guest_module/*.py diff --git a/modules/restricted-guests/synapse/CHANGELOG.md b/modules/restricted-guests/synapse/CHANGELOG.md new file mode 100644 index 0000000000..0bf2ec3720 --- /dev/null +++ b/modules/restricted-guests/synapse/CHANGELOG.md @@ -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. diff --git a/modules/restricted-guests/synapse/Dockerfile b/modules/restricted-guests/synapse/Dockerfile new file mode 100644 index 0000000000..3941d9511e --- /dev/null +++ b/modules/restricted-guests/synapse/Dockerfile @@ -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"] diff --git a/modules/restricted-guests/synapse/README.md b/modules/restricted-guests/synapse/README.md new file mode 100644 index 0000000000..3fb51aacba --- /dev/null +++ b/modules/restricted-guests/synapse/README.md @@ -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: ++ 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 + # ... +``` diff --git a/modules/restricted-guests/synapse/mypy.ini b/modules/restricted-guests/synapse/mypy.ini new file mode 100644 index 0000000000..be0671c339 --- /dev/null +++ b/modules/restricted-guests/synapse/mypy.ini @@ -0,0 +1,2 @@ +[mypy] +strict = true diff --git a/modules/restricted-guests/synapse/package.json b/modules/restricted-guests/synapse/package.json new file mode 100644 index 0000000000..d2f45f0442 --- /dev/null +++ b/modules/restricted-guests/synapse/package.json @@ -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" + } +} diff --git a/modules/restricted-guests/synapse/pyproject.toml b/modules/restricted-guests/synapse/pyproject.toml new file mode 100644 index 0000000000..827968917c --- /dev/null +++ b/modules/restricted-guests/synapse/pyproject.toml @@ -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" +] diff --git a/modules/restricted-guests/synapse/scripts/run_in_venv.js b/modules/restricted-guests/synapse/scripts/run_in_venv.js new file mode 100644 index 0000000000..12146be693 --- /dev/null +++ b/modules/restricted-guests/synapse/scripts/run_in_venv.js @@ -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 +// `/.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(); diff --git a/modules/restricted-guests/synapse/setup.cfg b/modules/restricted-guests/synapse/setup.cfg new file mode 100644 index 0000000000..a810e40931 --- /dev/null +++ b/modules/restricted-guests/synapse/setup.cfg @@ -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 diff --git a/modules/restricted-guests/synapse/synapse_guest_module/__init__.py b/modules/restricted-guests/synapse/synapse_guest_module/__init__.py new file mode 100644 index 0000000000..1b9ff4fa47 --- /dev/null +++ b/modules/restricted-guests/synapse/synapse_guest_module/__init__.py @@ -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"] diff --git a/modules/restricted-guests/synapse/synapse_guest_module/config.py b/modules/restricted-guests/synapse/synapse_guest_module/config.py new file mode 100644 index 0000000000..b522f56e0f --- /dev/null +++ b/modules/restricted-guests/synapse/synapse_guest_module/config.py @@ -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 diff --git a/modules/restricted-guests/synapse/synapse_guest_module/guest_module.py b/modules/restricted-guests/synapse/synapse_guest_module/guest_module.py new file mode 100644 index 0000000000..51af4fdc00 --- /dev/null +++ b/modules/restricted-guests/synapse/synapse_guest_module/guest_module.py @@ -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 diff --git a/modules/restricted-guests/synapse/synapse_guest_module/guest_registration_servlet.py b/modules/restricted-guests/synapse/synapse_guest_module/guest_registration_servlet.py new file mode 100644 index 0000000000..d3ec94c4ab --- /dev/null +++ b/modules/restricted-guests/synapse/synapse_guest_module/guest_registration_servlet.py @@ -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"} diff --git a/modules/restricted-guests/synapse/synapse_guest_module/guest_user_reaper.py b/modules/restricted-guests/synapse/synapse_guest_module/guest_user_reaper.py new file mode 100644 index 0000000000..9fd6c59877 --- /dev/null +++ b/modules/restricted-guests/synapse/synapse_guest_module/guest_user_reaper.py @@ -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 diff --git a/modules/restricted-guests/synapse/synapse_guest_module/py.typed b/modules/restricted-guests/synapse/synapse_guest_module/py.typed new file mode 100644 index 0000000000..e69de29bb2 diff --git a/modules/restricted-guests/synapse/tests/__init__.py b/modules/restricted-guests/synapse/tests/__init__.py new file mode 100644 index 0000000000..b642bd56ef --- /dev/null +++ b/modules/restricted-guests/synapse/tests/__init__.py @@ -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)" + ) diff --git a/modules/restricted-guests/synapse/tests/test_guest_module.py b/modules/restricted-guests/synapse/tests/test_guest_module.py new file mode 100644 index 0000000000..fefc669641 --- /dev/null +++ b/modules/restricted-guests/synapse/tests/test_guest_module.py @@ -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) diff --git a/modules/restricted-guests/synapse/tests/test_guest_registration_servlet.py b/modules/restricted-guests/synapse/tests/test_guest_registration_servlet.py new file mode 100644 index 0000000000..ccbfdad569 --- /dev/null +++ b/modules/restricted-guests/synapse/tests/test_guest_registration_servlet.py @@ -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)") diff --git a/modules/restricted-guests/synapse/tests/test_guest_user_reaper.py b/modules/restricted-guests/synapse/tests/test_guest_user_reaper.py new file mode 100644 index 0000000000..e85a55c980 --- /dev/null +++ b/modules/restricted-guests/synapse/tests/test_guest_user_reaper.py @@ -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"]}, + ), + ] + ) diff --git a/modules/restricted-guests/synapse/tox.ini b/modules/restricted-guests/synapse/tox.ini new file mode 100644 index 0000000000..1410d2ee59 --- /dev/null +++ b/modules/restricted-guests/synapse/tox.ini @@ -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