diff --git a/modules/restricted-guests/synapse/README.md b/modules/restricted-guests/synapse/README.md index 11fbc70afe..4cf5c743ce 100644 --- a/modules/restricted-guests/synapse/README.md +++ b/modules/restricted-guests/synapse/README.md @@ -41,6 +41,18 @@ The module provides (optional) configuration options: - `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). +If matrix-authentication-service (MAS) is configured, the module will need to +interface with it in order to register/deactivate users. Provide the below +options in order to give the module access to [MAS' Admin +API](https://element-hq.github.io/matrix-authentication-service/topics/admin-api.html). + +- `mas` - optional configuration for Matrix Authentication Service (MAS). When set, the module creates users via MAS' admin API. + - `admin_api_base_url` - Base URL for MAS' admin API (e.g. `https://mas.example.org`). Trailing slashes will be automatically stripped. + - `oauth_base_url` - Base URL for MAS' OAuth endpoints (defaults to `admin_api_base_url` if not set). Trailing slashes will be automatically stripped. + - `client_id` - client ID for the automated tool. Must be a valid [ULID](https://github.com/ulid/spec). Generate one [here](https://ulidtools.com/). + - `client_secret` - client secret for the automated tool. Ideally long and cryptographically secure. Keep it a secret! + - `client_secret_filepath` - path to a plaintext file containing the client secret. If set, this is used instead of `client_secret`. + Example configuration: ```yaml @@ -49,6 +61,39 @@ modules: config: # Use a german suffix display_name_suffix: " (Gast)" + # The below is required if using MAS + mas: + admin_api_base_url: https://mas.example.org + oauth_base_url: https://mas.example.org + # The `client_id` must be a valid ULID: + # https://github.com/ulid/spec + # Generate ULID's easily at: + # https://ulidtools.com/ + client_id: 000000000000000000000G0EST + client_secret: your-client-secret + # Alternatively, load the secret from a file: + # client_secret_filepath: /run/secrets/mas-client-secret +``` + +Enable [the Admin API on a MAS +listener](https://element-hq.github.io/matrix-authentication-service/topics/admin-api.html#enabling-the-api). +Then, add the following to your MAS config file: + +```yaml +policy: + data: + admin_clients: + - 000000000000000000000G0EST + +# ... + +clients: + # The `client_id` must be a valid ULID https://github.com/ulid/spec + # Generate ULID's easily at: https://ulidtools.com/ + - client_id: 000000000000000000000G0EST + # The guest module uses the client_secret_basic authentication method. + client_auth_method: client_secret_basic + client_secret: your-client-secret ``` ## Production installation diff --git a/modules/restricted-guests/synapse/setup.cfg b/modules/restricted-guests/synapse/setup.cfg index 75fead2743..d190edbb11 100644 --- a/modules/restricted-guests/synapse/setup.cfg +++ b/modules/restricted-guests/synapse/setup.cfg @@ -30,6 +30,7 @@ dev = twisted aiounittest coverage + parameterized # for type checking mypy == 1.10.0 pydantic == 2.4.2 diff --git a/modules/restricted-guests/synapse/synapse_guest_module/config.py b/modules/restricted-guests/synapse/synapse_guest_module/config.py index a0ddbe569a..7841d1433e 100644 --- a/modules/restricted-guests/synapse/synapse_guest_module/config.py +++ b/modules/restricted-guests/synapse/synapse_guest_module/config.py @@ -7,12 +7,24 @@ # Originally licensed under the Apache License, Version 2.0: # . +from typing import Optional + import attr +@attr.s(frozen=True, auto_attribs=True) +class MasConfig: + admin_api_base_url: str + oauth_base_url: str + client_id: str + client_secret: Optional[str] = None + client_secret_filepath: Optional[str] = None + + @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 + mas: Optional[MasConfig] = None diff --git a/modules/restricted-guests/synapse/synapse_guest_module/guest_module.py b/modules/restricted-guests/synapse/synapse_guest_module/guest_module.py index 156012c35d..1160ee6c93 100644 --- a/modules/restricted-guests/synapse/synapse_guest_module/guest_module.py +++ b/modules/restricted-guests/synapse/synapse_guest_module/guest_module.py @@ -7,11 +7,13 @@ # Originally licensed under the Apache License, Version 2.0: # . +import asyncio import logging -from typing import Any, Dict, Literal, Tuple, Union +from typing import Any, Dict, Literal, Optional, Tuple, Union from synapse.module_api import ( NOT_SPAM, + LoggingTransaction, ModuleApi, ProfileInfo, UserProfile, @@ -21,9 +23,10 @@ from synapse.module_api import ( from synapse.module_api.errors import ConfigError from synapse.types import UserID -from synapse_guest_module.config import GuestModuleConfig +from synapse_guest_module.config import GuestModuleConfig, MasConfig from synapse_guest_module.guest_registration_servlet import GuestRegistrationServlet from synapse_guest_module.guest_user_reaper import GuestUserReaper +from synapse_guest_module.mas_admin_client import MasAdminClient logger = logging.getLogger("synapse.contrib." + __name__) @@ -32,8 +35,21 @@ class GuestModule: def __init__(self, config: GuestModuleConfig, api: ModuleApi): self._api = api self._config = config + self._mas_tables_ready: asyncio.Event | None = None - self.registration_servlet = GuestRegistrationServlet(config, api) + mas_admin_client = ( + MasAdminClient(api, config.mas) if config.mas is not None else None + ) + if config.mas is not None: + self._mas_tables_ready = asyncio.Event() + run_as_background_process( + "guest_module_mas_db_init", + self._init_mas_tables, + bg_start_span=False, + ) + self.registration_servlet = GuestRegistrationServlet( + config, api, mas_admin_client, self._mas_tables_ready + ) self._api.register_web_resource( "/_synapse/client/register_guest", self.registration_servlet ) @@ -48,7 +64,9 @@ class GuestModule: ) # Start the user reaper - self.reaper = GuestUserReaper(api, config) + self.reaper = GuestUserReaper( + api, config, mas_admin_client, self._mas_tables_ready + ) if config.enable_user_reaper: run_as_background_process( "guest_module_reaper_bg_task", @@ -81,11 +99,77 @@ class GuestModule: "Config option 'user_expiration_seconds' must be a number" ) + mas_config = config.get("mas") + mas: Optional[MasConfig] = None + if mas_config is not None: + if not isinstance(mas_config, dict): + raise ConfigError("Config option 'mas' must be an object") + + admin_api_base_url = mas_config.get("admin_api_base_url") + if ( + not isinstance(admin_api_base_url, str) + or len(admin_api_base_url.strip()) == 0 + ): + raise ConfigError( + "Config option 'mas.admin_api_base_url' is required and must be a string" + ) + + oauth_base_url = mas_config.get("oauth_base_url", admin_api_base_url) + if not isinstance(oauth_base_url, str) or len(oauth_base_url.strip()) == 0: + raise ConfigError("Config option 'mas.oauth_base_url' must be a string") + + client_id = mas_config.get("client_id") + if not isinstance(client_id, str) or len(client_id.strip()) == 0: + raise ConfigError( + "Config option 'mas.client_id' is required and must be a string" + ) + + client_secret = mas_config.get("client_secret") + if client_secret is not None: + if ( + not isinstance(client_secret, str) + or len(client_secret.strip()) == 0 + ): + raise ConfigError( + "Config option 'mas.client_secret' must be a string" + ) + client_secret = client_secret.strip() + + client_secret_filepath = mas_config.get("client_secret_filepath") + if client_secret_filepath is not None: + if ( + not isinstance(client_secret_filepath, str) + or len(client_secret_filepath.strip()) == 0 + ): + raise ConfigError( + "Config option 'mas.client_secret_filepath' must be a string" + ) + client_secret_filepath = client_secret_filepath.strip() + + if client_secret is None and client_secret_filepath is None: + raise ConfigError( + "Config option 'mas.client_secret' or 'mas.client_secret_filepath' is required" + ) + + if client_secret is not None and client_secret_filepath is not None: + raise ConfigError( + "Config option 'mas.client_secret' and 'mas.client_secret_filepath' are mutually exclusive" + ) + + mas = MasConfig( + admin_api_base_url.strip(), + oauth_base_url.strip(), + client_id.strip(), + client_secret, + client_secret_filepath, + ) + return GuestModuleConfig( user_id_prefix, display_name_suffix, enable_user_reaper, user_expiration_seconds, + mas, ) async def profile_update( @@ -114,6 +198,40 @@ class GuestModule: ) await self._api.set_displayname(user_id_1, guest_display_name) + async def _init_mas_tables(self) -> None: + if self._mas_tables_ready is None: + return + + try: + await self._api.run_db_interaction( + "guest_module_create_mas_tables", + self._create_mas_tables, + ) + except Exception as err: + logger.error("Failed to initialize MAS tables: %s", err) + finally: + self._mas_tables_ready.set() + + @staticmethod + def _create_mas_tables(txn: LoggingTransaction) -> None: + txn.execute( + """ + CREATE TABLE IF NOT EXISTS guest_module_mas_users ( + mas_user_id TEXT PRIMARY KEY NOT NULL, + user_id TEXT NOT NULL, + created_at_sec BIGINT NOT NULL + ) + """, + (), + ) + txn.execute( + """ + CREATE INDEX IF NOT EXISTS guest_module_mas_users_created_at_sec + ON guest_module_mas_users (created_at_sec) + """, + (), + ) + async def callback_user_may_create_room( self, user_id: str, 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 index 6cdb382831..d11e1ffb05 100644 --- a/modules/restricted-guests/synapse/synapse_guest_module/guest_registration_servlet.py +++ b/modules/restricted-guests/synapse/synapse_guest_module/guest_registration_servlet.py @@ -7,19 +7,24 @@ # Originally licensed under the Apache License, Version 2.0: # . +import asyncio import logging import secrets import string +import time from typing import Any, Dict, Tuple from synapse.module_api import ( + DatabasePool, DirectServeJsonResource, ModuleApi, parse_json_object_from_request, ) +from synapse.types import UserID from twisted.web.server import Request from synapse_guest_module.config import GuestModuleConfig +from synapse_guest_module.mas_admin_client import MasAdminClient logger = logging.getLogger("synapse.contrib." + __name__) @@ -35,10 +40,14 @@ class GuestRegistrationServlet(DirectServeJsonResource): self, config: GuestModuleConfig, api: ModuleApi, + mas_admin_client: MasAdminClient | None = None, + mas_tables_ready: asyncio.Event | None = None, ): super().__init__() self._api = api self._config = config + self._mas_admin_client = mas_admin_client + self._mas_tables_ready = mas_tables_ready 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 @@ -52,8 +61,11 @@ class GuestRegistrationServlet(DirectServeJsonResource): 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 + displayname = displayname.strip() + + # Attempt up to 10 times to generate a localpart for _ in range(10): + # generate a random string as a suffix random_string = "".join( secrets.choice(string.ascii_lowercase + string.digits) for _ in range(32) @@ -67,14 +79,52 @@ class GuestRegistrationServlet(DirectServeJsonResource): ): continue - logger.info("Register guest with user %s", localpart) - user_id = await self._api.register_user( - localpart, displayname.strip() + self._config.display_name_suffix - ) + if self._mas_admin_client is None: + logger.info( + "Registering local Synapse guest user with localpart '%s'", + localpart, + ) + user_id = await self._api.register_user( + localpart, displayname + self._config.display_name_suffix + ) - device_id, access_token, _, _ = await self._api.register_device(user_id) + device_id, access_token, _, _ = await self._api.register_device(user_id) + else: + logger.info("Registering MAS guest user with localpart '%s'", localpart) - logger.debug("Registered user %s", user_id) + # This will be the MAS-specific user ID (i.e. "01KFNJEB720EAGR907PSXRXQ51") + mas_user_id = await self._mas_admin_client.create_user(localpart) + # This is the Matrix user ID (i.e. "@guest_abc123:matrix.org") + user_id = self._api.get_qualified_user_id(localpart) + + logger.info( + f"Registered guest user: '{user_id}' (MAS ID: '{mas_user_id}')" + ) + + await self._api.set_displayname( + UserID.from_string(user_id), + displayname + self._config.display_name_suffix, + ) + + await self._store_mas_user(mas_user_id, user_id, int(time.time())) + + # Determine how long to keep the access token valid for. + # + # If a user reaper is enabled, just have the token expire after + # the configured period. + expires_in_sec = ( + self._config.user_expiration_seconds + if self._config.enable_user_reaper + else 0 + ) + ( + device_id, + access_token, + ) = await self._mas_admin_client.create_personal_session( + mas_user_id, expires_in_sec + ) + + logger.debug("Registered user '%s'", user_id) res = { "userId": user_id, @@ -86,3 +136,29 @@ class GuestRegistrationServlet(DirectServeJsonResource): return 201, res return 500, {"msg": "Internal error: Could not find a free username"} + + async def _store_mas_user( + self, mas_user_id: str, user_id: str, created_at_sec: int + ) -> None: + """Store details about the MAS user in the DB + + Args: + mas_user_id: The MAS user ID + user_id: The Matrix user ID + created_at_sec: The creation timestamp in seconds since the unix epoch + """ + if self._mas_tables_ready is not None: + await self._mas_tables_ready.wait() + + def store_user(txn: Any) -> None: + DatabasePool.simple_insert_txn( + txn, + table="guest_module_mas_users", + values={ + "mas_user_id": mas_user_id, + "user_id": user_id, + "created_at_sec": created_at_sec, + }, + ) + + await self._api.run_db_interaction("guest_module_store_mas_user", store_user) 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 index 7b3f2c7288..24fd5db2ce 100644 --- a/modules/restricted-guests/synapse/synapse_guest_module/guest_user_reaper.py +++ b/modules/restricted-guests/synapse/synapse_guest_module/guest_user_reaper.py @@ -7,6 +7,7 @@ # Originally licensed under the Apache License, Version 2.0: # . +import asyncio import logging import time from typing import List @@ -14,14 +15,23 @@ from typing import List from synapse.module_api import DatabasePool, LoggingTransaction, ModuleApi from synapse_guest_module.config import GuestModuleConfig +from synapse_guest_module.mas_admin_client import MasAdminClient logger = logging.getLogger("synapse.contrib." + __name__) class GuestUserReaper: - def __init__(self, api: ModuleApi, config: GuestModuleConfig): + def __init__( + self, + api: ModuleApi, + config: GuestModuleConfig, + mas_admin_client: MasAdminClient | None = None, + mas_tables_ready: asyncio.Event | None = None, + ): self._api = api self._config = config + self._mas_admin_client = mas_admin_client + self._mas_tables_ready = mas_tables_ready self.reaper_user = f"{config.user_id_prefix}reaper" async def run(self) -> None: @@ -43,6 +53,9 @@ class GuestUserReaper: """Deactivate all users that are older than the specified expiration interval. This uses the admin API to disable the user. """ + if self._mas_admin_client is not None: + await self._deactivate_expired_mas_users() + return def get_expired_users(txn: LoggingTransaction) -> List[str]: sql = """ @@ -92,6 +105,61 @@ class GuestUserReaper: except Exception as e: logger.error('Failed to delete user "%s": %s', user_id, e) + async def _deactivate_expired_mas_users(self) -> None: + """Deactivate all MAS users that are older than the specified expiration + interval. This uses the MAS admin API to disable the user. + """ + + assert self._mas_admin_client is not None + + if self._mas_tables_ready is not None: + await self._mas_tables_ready.wait() + + def get_expired_users(txn: LoggingTransaction) -> List[str]: + expire_ts_seconds = int(time.time() - self._config.user_expiration_seconds) + txn.execute( + """ + SELECT mas_user_id + FROM guest_module_mas_users + WHERE created_at_sec < ? + """, + (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_mas_users", + get_expired_users, + ) + + if len(expired_users) == 0: + return + + logger.info("Deactivating %d expired MAS users", len(expired_users)) + + token = await self._mas_admin_client.request_admin_token() + + for mas_user_id in expired_users: + try: + await self._mas_admin_client.deactivate_user(mas_user_id, token) + await self._remove_mas_user(mas_user_id) + except Exception as e: + logger.error('Failed to deactivate MAS user "%s": %s', mas_user_id, e) + + async def _remove_mas_user(self, mas_user_id: str) -> None: + def delete_user(txn: LoggingTransaction) -> None: + txn.execute( + "DELETE FROM guest_module_mas_users WHERE mas_user_id = ?", + (mas_user_id,), + ) + + await self._api.run_db_interaction( + "guest_module_delete_mas_user", + delete_user, + ) + 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. diff --git a/modules/restricted-guests/synapse/synapse_guest_module/mas_admin_client.py b/modules/restricted-guests/synapse/synapse_guest_module/mas_admin_client.py new file mode 100644 index 0000000000..bde247e1ac --- /dev/null +++ b/modules/restricted-guests/synapse/synapse_guest_module/mas_admin_client.py @@ -0,0 +1,185 @@ +# Copyright 2025 New Vector Ltd. +# +# SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +# Please see LICENSE files in the project root for full details. + +import base64 +import logging +import random +import string + +from synapse.module_api import ModuleApi + +from synapse_guest_module.config import MasConfig + +logger = logging.getLogger("synapse.contrib." + __name__) + + +class MasAdminClient: + def __init__(self, api: ModuleApi, config: MasConfig): + self._api = api + self._config = config + # Strip any trailing slashes if present + self._admin_api_base_url = config.admin_api_base_url.rstrip("/") + self._oauth_base_url = config.oauth_base_url.rstrip("/") + self._client_secret = self._load_client_secret() + + async def create_user(self, username: str) -> str: + """Creates a new user in MAS with the given username. + + Args: + username: The username (localpart) of the user to create. + + Returns: + The MAS ID of the created user. + """ + token = await self.request_admin_token() + url = self._build_admin_url("/api/admin/v1/users") + + response = await self._api.http_client.post_json_get_json( + uri=url, + post_json={"username": username}, + headers={"Authorization": [f"Bearer {token}"]}, + ) + + mas_user_id: str = response.get("data", {}).get("id") + if mas_user_id is None or not isinstance(mas_user_id, str): + raise ValueError("MAS user creation response missing `data.id` field") + + return mas_user_id + + async def create_personal_session( + self, mas_user_id: str, expires_in_sec: int + ) -> tuple[str, str]: + """Creates a new personal session for the given MAS user. + + Args: + mas_user_id: The MAS user ID. + expires_in_sec: The session expiration time in seconds. + + Returns: + A tuple of (device_id, access_token). + """ + token = await self.request_admin_token() + url = self._build_admin_url("/api/admin/v1/personal-sessions") + + device_id = self._generate_device_id() + request_body = { + "actor_user_id": mas_user_id, + "expires_in": expires_in_sec, + "scope": f"openid urn:matrix:client:api:* urn:matrix:client:device:{device_id}", + "human_name": "guest session", + } + + response = await self._api.http_client.post_json_get_json( + uri=url, + post_json=request_body, + headers={"Authorization": [f"Bearer {token}"]}, + ) + + data = response.get("data", {}) + attributes = data.get("attributes", {}) if isinstance(data, dict) else {} + access_token = attributes.get("access_token") + + if not isinstance(access_token, str) or len(access_token) == 0: + raise ValueError("MAS session response missing `access_token` field") + + return device_id, access_token + + async def deactivate_user(self, mas_user_id: str, token: str | None = None) -> None: + if token is None: + token = await self.request_admin_token() + + url = self._build_admin_url(f"/api/admin/v1/users/{mas_user_id}/deactivate") + await self._api.http_client.post_json_get_json( + uri=url, + post_json={"skip_erase": True}, + headers={"Authorization": [f"Bearer {token}"]}, + ) + + async def request_admin_token(self) -> str: + """ + Uses the client credentials flow to request an admin access token + from MAS. + + Returns: + The admin access token. + + Raises: + ValueError: If the token response is invalid. + HttpResponseException: On a non-2xx HTTP response. + """ + url = self._build_oauth_url("/oauth2/token") + basic_auth = base64.b64encode( + f"{self._config.client_id}:{self._client_secret}".encode("utf-8") + ).decode("ascii") + headers = { + "Authorization": [f"Basic {basic_auth}"], + "Content-Type": ["application/x-www-form-urlencoded"], + } + data = { + "grant_type": "client_credentials", + "scope": "urn:mas:admin", + } + + response = await self._api.http_client.post_urlencoded_get_json( + url, data, headers + ) + access_token = response.get("access_token") + if not isinstance(access_token, str) or len(access_token) == 0: + raise ValueError("MAS token response missing access_token") + return access_token + + def _load_client_secret(self) -> str: + """Source the MAS client secret from either configuration or a file.""" + if self._config.client_secret_filepath is not None: + try: + with open( + self._config.client_secret_filepath, "r", encoding="utf-8" + ) as secret_file: + client_secret = secret_file.read().strip() + except Exception as err: + raise ValueError( + f"Failed to read MAS client secret file: {err}" + ) from err + + if len(client_secret) == 0: + raise ValueError("MAS client secret file is empty") + + return client_secret + + if self._config.client_secret is None: + raise ValueError("MAS client secret is not configured") + + client_secret = self._config.client_secret.strip() + if len(client_secret) == 0: + raise ValueError("MAS client secret is empty") + + return client_secret + + @staticmethod + def _generate_device_id() -> str: + """Generate a MAS device ID. + + Device IDs must be at least 10 characters long and contain only + [A-Za-z0-9-]. + + The generated device ID is purposefully non-cryptographically random, + as the value is public. + + Returns: + The generated Device ID. + """ + length = 16 + alphabet = string.ascii_letters + string.digits + "-" + return "".join(random.choices(alphabet, k=length)) + + def _build_admin_url(self, path: str) -> str: + if not path.startswith("/"): + path = "/" + path + return f"{self._admin_api_base_url}{path}" + + def _build_oauth_url(self, path: str) -> str: + if not path.startswith("/"): + path = "/" + path + return f"{self._oauth_base_url}{path}" diff --git a/modules/restricted-guests/synapse/tests/__init__.py b/modules/restricted-guests/synapse/tests/__init__.py index 04d9c250b8..4f990b54f2 100644 --- a/modules/restricted-guests/synapse/tests/__init__.py +++ b/modules/restricted-guests/synapse/tests/__init__.py @@ -9,7 +9,7 @@ import sqlite3 from asyncio import Future -from typing import Any, Awaitable, Callable, Tuple, TypeVar +from typing import Any, Awaitable, Callable, Dict, Tuple, TypeVar from unittest.mock import Mock from synapse.http.client import SimpleHttpClient @@ -89,7 +89,9 @@ async def register_user(localpart: str, admin: bool = False) -> str: return f"@{localpart}:matrix.local" -def create_module() -> Tuple[GuestModule, Mock, SQLiteStore]: +def create_module( + config_override: Dict[str, Any] | None = None, +) -> Tuple[GuestModule, Mock, SQLiteStore]: store = SQLiteStore() _setup_db(store.conn) @@ -111,19 +113,38 @@ def create_module() -> Tuple[GuestModule, Mock, SQLiteStore]: ) # If necessary, give parse_config some configuration to parse. - config = GuestModule.parse_config( - { - "enable_user_reaper": False, - } - ) + config_dict: Dict[str, Any] = { + "enable_user_reaper": False, + } + if config_override is not None: + config_dict.update(config_override) + + config = GuestModule.parse_config(config_dict) module = GuestModule(config, module_api) + if getattr(module, "_mas_tables_ready", None) is not None: + module._mas_tables_ready.set() # type: ignore[union-attr] + return module, module_api, store +def mas_config_override() -> Dict[str, Any]: + return { + "mas": { + "admin_api_base_url": "https://mas.example.org", + "oauth_base_url": "https://oauth.mas.example.org", + "client_id": "client-id", + "client_secret": "client-secret", + }, + } + + 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)" ) + conn.execute( + "CREATE TABLE guest_module_mas_users(mas_user_id text, user_id text, created_at_sec bigint)" + ) diff --git a/modules/restricted-guests/synapse/tests/test_guest_module.py b/modules/restricted-guests/synapse/tests/test_guest_module.py index f92614c714..714e1fd918 100644 --- a/modules/restricted-guests/synapse/tests/test_guest_module.py +++ b/modules/restricted-guests/synapse/tests/test_guest_module.py @@ -7,17 +7,21 @@ # Originally licensed under the Apache License, Version 2.0: # . +from typing import Tuple +from unittest.mock import Mock + import aiounittest +from parameterized import parameterized_class # type: ignore[import-untyped] 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.config import GuestModuleConfig, MasConfig from synapse_guest_module.guest_module import GuestModule -from tests import create_module +from tests import SQLiteStore, create_module, mas_config_override -class GuestModuleTest(aiounittest.AsyncTestCase): +class GuestModuleConfigTest(aiounittest.AsyncTestCase): async def test_parse_config_empty(self) -> None: config = GuestModule.parse_config({}) @@ -28,10 +32,11 @@ class GuestModuleTest(aiounittest.AsyncTestCase): display_name_suffix=" (Guest)", enable_user_reaper=True, user_expiration_seconds=24 * 60 * 60, + mas=None, ), ) - async def test_parse_config_custom(self) -> None: + async def test_parse_config_no_mas(self) -> None: config = GuestModule.parse_config( { "user_id_prefix": "tmp-", @@ -48,6 +53,35 @@ class GuestModuleTest(aiounittest.AsyncTestCase): display_name_suffix=" (Temporary)", enable_user_reaper=False, user_expiration_seconds=100, + mas=None, + ), + ) + + async def test_parse_config_mas(self) -> None: + config = GuestModule.parse_config( + { + "mas": { + "admin_api_base_url": "https://mas.example.org", + "client_id": "client-id", + "client_secret": "client-secret", + }, + } + ) + + self.assertEqual( + config, + GuestModuleConfig( + user_id_prefix="guest-", + display_name_suffix=" (Guest)", + enable_user_reaper=True, + user_expiration_seconds=24 * 60 * 60, + mas=MasConfig( + admin_api_base_url="https://mas.example.org", + oauth_base_url="https://mas.example.org", + client_id="client-id", + client_secret="client-secret", + client_secret_filepath=None, + ), ), ) @@ -91,8 +125,20 @@ class GuestModuleTest(aiounittest.AsyncTestCase): } ) + +@parameterized_class( + ("variant", "config_override"), + [ + ("synapse", None), + ("mas", mas_config_override()), + ], +) +class GuestModuleRuntimeTest(aiounittest.AsyncTestCase): + def create_module(self) -> Tuple[GuestModule, Mock, SQLiteStore]: + return create_module(self.config_override) + async def test_profile_update_no_guest(self) -> None: - module, module_api, _ = create_module() + module, module_api, _ = self.create_module() await module.profile_update( "@my-user:matrix.local", @@ -104,7 +150,7 @@ class GuestModuleTest(aiounittest.AsyncTestCase): module_api.set_displayname.assert_not_called() async def test_profile_update_guest_keep(self) -> None: - module, module_api, _ = create_module() + module, module_api, _ = self.create_module() await module.profile_update( "@guest-asdf:matrix.local", @@ -116,7 +162,7 @@ class GuestModuleTest(aiounittest.AsyncTestCase): module_api.set_displayname.assert_not_called() async def test_profile_update_guest_add_and_trim(self) -> None: - module, module_api, _ = create_module() + module, module_api, _ = self.create_module() await module.profile_update( "@guest-asdf:matrix.local", @@ -131,7 +177,7 @@ class GuestModuleTest(aiounittest.AsyncTestCase): ) async def test_callback_user_may_create_room_no_guest(self) -> None: - module, _, _ = create_module() + module, _, _ = self.create_module() allow = await module.callback_user_may_create_room( "@my-user:matrix.local", @@ -140,7 +186,7 @@ class GuestModuleTest(aiounittest.AsyncTestCase): self.assertTrue(allow) async def test_callback_user_may_create_room_guest(self) -> None: - module, _, _ = create_module() + module, _, _ = self.create_module() allow = await module.callback_user_may_create_room( "@guest-asdf:matrix.local", @@ -149,7 +195,7 @@ class GuestModuleTest(aiounittest.AsyncTestCase): self.assertFalse(allow) async def test_callback_user_may_invite_no_guest(self) -> None: - module, _, _ = create_module() + module, _, _ = self.create_module() allow = await module.callback_user_may_invite( "@my-user:matrix.local", @@ -160,7 +206,7 @@ class GuestModuleTest(aiounittest.AsyncTestCase): self.assertTrue(allow) async def test_callback_user_may_invite_guest(self) -> None: - module, _, _ = create_module() + module, _, _ = self.create_module() allow = await module.callback_user_may_invite( "@guest-asdf:matrix.local", @@ -171,7 +217,7 @@ class GuestModuleTest(aiounittest.AsyncTestCase): self.assertFalse(allow) async def test_callback_check_username_for_spam_no_guest(self) -> None: - module, _, _ = create_module() + module, _, _ = self.create_module() allow = await module.callback_check_username_for_spam( UserProfile( @@ -184,7 +230,7 @@ class GuestModuleTest(aiounittest.AsyncTestCase): self.assertFalse(allow) async def test_callback_check_username_for_spam_guest(self) -> None: - module, _, _ = create_module() + module, _, _ = self.create_module() allow = await module.callback_check_username_for_spam( UserProfile( diff --git a/modules/restricted-guests/synapse/tests/test_guest_registration_servlet.py b/modules/restricted-guests/synapse/tests/test_guest_registration_servlet.py index f0a63b7a62..b61c6400e0 100644 --- a/modules/restricted-guests/synapse/tests/test_guest_registration_servlet.py +++ b/modules/restricted-guests/synapse/tests/test_guest_registration_servlet.py @@ -8,19 +8,32 @@ # . import io -from typing import cast -from unittest.mock import ANY +from typing import Tuple, cast +from unittest.mock import ANY, Mock import aiounittest +from parameterized import parameterized_class # type: ignore[import-untyped] from twisted.web.server import Request from twisted.web.test.requesthelper import DummyRequest -from tests import create_module, make_awaitable +from synapse_guest_module import GuestModule +from synapse_guest_module.mas_admin_client import MasAdminClient +from tests import SQLiteStore, create_module, make_awaitable, mas_config_override +@parameterized_class( + ("variant", "config_override"), + [ + ("synapse", None), + ("mas", mas_config_override()), + ], +) class GuestUserReaperTest(aiounittest.AsyncTestCase): + def create_module(self) -> Tuple[GuestModule, Mock, SQLiteStore]: + return create_module(self.config_override) + async def test_async_render_POST_missing_displayname(self) -> None: - module, _, _ = create_module() + module, _, _ = self.create_module() request = cast(Request, DummyRequest([])) request.content = io.BytesIO(b"{}") @@ -33,7 +46,7 @@ class GuestUserReaperTest(aiounittest.AsyncTestCase): ) async def test_async_render_POST_empty_displayname(self) -> None: - module, _, _ = create_module() + module, _, _ = self.create_module() request = cast(Request, DummyRequest([])) request.content = io.BytesIO(b'{"displayname":" "}') @@ -46,7 +59,7 @@ class GuestUserReaperTest(aiounittest.AsyncTestCase): ) async def test_async_render_POST_no_free_username(self) -> None: - module, module_api, _ = create_module() + module, module_api, _ = self.create_module() request = cast(Request, DummyRequest([])) request.content = io.BytesIO(b'{"displayname":"My Name"}') @@ -63,24 +76,61 @@ class GuestUserReaperTest(aiounittest.AsyncTestCase): self.assertEqual(module_api.check_user_exists.call_count, 10) async def test_async_render_POST_success(self) -> None: - module, module_api, _ = create_module() + module, module_api, _ = self.create_module() request = cast(Request, DummyRequest([])) request.content = io.BytesIO(b'{"displayname":"My Name "}') + if self.config_override is not None: + module.registration_servlet._mas_admin_client._generate_device_id = ( # type: ignore[method-assign,union-attr] + lambda: "MASDEVICE123" + ) + + # `make_awaitable` is not needed here as both methods are already `AsyncMock`. + module_api.http_client.post_urlencoded_get_json.return_value = { + "access_token": "mas_admin_token" + } + module_api.http_client.post_json_get_json.side_effect = [ + {"data": {"id": "mas-user-id"}}, + { + "data": { + "id": "MASDEVICE123", + "attributes": {"access_token": "mas_access_token"}, + } + }, + ] + 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 - }, - ) + if self.config_override is None: + 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)") + else: + self.assertDictEqual( + response, + { + "accessToken": "mas_access_token", + "deviceId": "MASDEVICE123", + "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)") + +class MasAdminClientTest(aiounittest.AsyncTestCase): + async def test_generate_device_id_format(self) -> None: + device_id = MasAdminClient._generate_device_id() + + self.assertGreaterEqual(len(device_id), 10) + self.assertRegex(device_id, r"^[A-Za-z0-9-]+$") diff --git a/modules/restricted-guests/synapse/tests/test_guest_user_reaper.py b/modules/restricted-guests/synapse/tests/test_guest_user_reaper.py index 0a865c3f2d..960e3f999f 100644 --- a/modules/restricted-guests/synapse/tests/test_guest_user_reaper.py +++ b/modules/restricted-guests/synapse/tests/test_guest_user_reaper.py @@ -121,3 +121,46 @@ class GuestUserReaperTest(aiounittest.AsyncTestCase): ), ] ) + + async def test_deactivate_expired_mas_users_success(self) -> None: + module, module_api, store = create_module( + { + "mas": { + "admin_api_base_url": "https://mas.example.org", + "oauth_base_url": "https://oauth.mas.example.org", + "client_id": "client-id", + "client_secret": "client-secret", + }, + } + ) + + now = int(time.time()) + store.conn.executemany( + "INSERT INTO guest_module_mas_users VALUES (?, ?, ?)", + [ + ["mas-old-1", "@old-1:localhost", 0], + ["mas-active", "@active:localhost", now], + ], + ) + + # These two methods are `AsyncMock`s, so no need to use `make_awaitable`.` + token_response = {"access_token": "mas_admin_token"} + module_api.http_client.post_urlencoded_get_json.return_value = token_response + module_api.http_client.post_json_get_json.return_value = {} + + await module.reaper.deactivate_expired_guest_users() + + deactivate_call = call( + uri="https://mas.example.org/api/admin/v1/users/mas-old-1/deactivate", + post_json={"skip_erase": True}, + headers={"Authorization": ["Bearer mas_admin_token"]}, + ) + self.assertIn( + deactivate_call, + module_api.http_client.post_json_get_json.await_args_list, + ) + + remaining_users = store.conn.execute( + "SELECT mas_user_id, user_id FROM guest_module_mas_users" + ).fetchall() + self.assertEqual(remaining_users, [("mas-active", "@active:localhost")])