From 1cb888e4e726a34cbbcb5d8c9c5c38461ae4902a Mon Sep 17 00:00:00 2001 From: Andrew Morgan Date: Wed, 14 Jan 2026 15:45:46 +0000 Subject: [PATCH 01/24] Configuration options for MAS In order to create and manage users with MAS enabled, we'll need to reach out to the MAS admin API. We can do so automatically by requesting an admin-enabled token, assuming a matching client has been configured on the MAS side. Add some config options for the guest module (OAuth2 client) side. --- modules/restricted-guests/synapse/README.md | 42 ++++++++++++++++++ .../synapse/synapse_guest_module/config.py | 12 +++++ .../synapse_guest_module/guest_module.py | 44 +++++++++++++++++-- 3 files changed, 95 insertions(+), 3 deletions(-) diff --git a/modules/restricted-guests/synapse/README.md b/modules/restricted-guests/synapse/README.md index 11fbc70afe..e3ed2ccac8 100644 --- a/modules/restricted-guests/synapse/README.md +++ b/modules/restricted-guests/synapse/README.md @@ -41,6 +41,17 @@ 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! + Example configuration: ```yaml @@ -49,6 +60,37 @@ 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 +``` + +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/synapse_guest_module/config.py b/modules/restricted-guests/synapse/synapse_guest_module/config.py index a0ddbe569a..5e54f2f642 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 + # TODO: Add a filepath option for the secret as well. + client_secret: str + + @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..174c3474f6 100644 --- a/modules/restricted-guests/synapse/synapse_guest_module/guest_module.py +++ b/modules/restricted-guests/synapse/synapse_guest_module/guest_module.py @@ -8,7 +8,7 @@ # . 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, @@ -21,7 +21,8 @@ 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.mas_admin_client import MasAdminClient from synapse_guest_module.guest_registration_servlet import GuestRegistrationServlet from synapse_guest_module.guest_user_reaper import GuestUserReaper @@ -33,7 +34,12 @@ class GuestModule: self._api = api self._config = config - self.registration_servlet = GuestRegistrationServlet(config, api) + mas_admin_client = ( + MasAdminClient(api, config.mas) if config.mas is not None else None + ) + self.registration_servlet = GuestRegistrationServlet( + config, api, mas_admin_client + ) self._api.register_web_resource( "/_synapse/client/register_guest", self.registration_servlet ) @@ -81,11 +87,43 @@ 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 not isinstance(client_secret, str) or len(client_secret.strip()) == 0: + raise ConfigError("Config option 'mas.client_secret' is required and must be a string") + + mas = MasConfig( + admin_api_base_url.strip(), + oauth_base_url.strip(), + client_id.strip(), + client_secret.strip(), + ) + return GuestModuleConfig( user_id_prefix, display_name_suffix, enable_user_reaper, user_expiration_seconds, + mas, ) async def profile_update( From 04414e06a9bd18453e8321fb626682c9858e4f0e Mon Sep 17 00:00:00 2001 From: Andrew Morgan Date: Wed, 14 Jan 2026 15:46:49 +0000 Subject: [PATCH 02/24] Add `MasAdminClient` class A class to request OAuth2 tokens from MAS, and create users using them. Requires data from the config options we defined. --- .../synapse_guest_module/mas_admin_client.py | 78 +++++++++++++++++++ 1 file changed, 78 insertions(+) create mode 100644 modules/restricted-guests/synapse/synapse_guest_module/mas_admin_client.py 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..50fd38dae5 --- /dev/null +++ b/modules/restricted-guests/synapse/synapse_guest_module/mas_admin_client.py @@ -0,0 +1,78 @@ +# 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 +from typing import Any, Dict + +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 trailing any slashes if present + self._admin_api_base_url = config.admin_api_base_url.rstrip("/") + self._oauth_base_url = config.oauth_base_url.rstrip("/") + + async def create_user(self, username: str) -> None: + token = await self.request_admin_token() + url = self._build_admin_url("/api/admin/v1/users") + + await self._api.http_client.post_json_get_json( + uri=url, + post_json={"username": username}, + headers={"Authorization": [f"Bearer {token}"]}, + ) + + async def request_admin_token(self) -> str: + url = self._build_oauth_url("/oauth2/token") + basic_auth = base64.b64encode( + f"{self._config.client_id}:{self._config.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._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 + + async def _post_urlencoded_get_json( + self, url: str, data: Dict[str, str], headers: Dict[str, Any] + ) -> Any: + http_client = self._api.http_client + post_urlencoded = getattr(http_client, "post_urlencoded_get_json", None) + if callable(post_urlencoded): + return await post_urlencoded(url, data, headers=headers) + + logger.debug( + "MAS client falling back to post_json_get_json for %s", url + ) + return await http_client.post_json_get_json( + uri=url, post_json=data, headers=headers + ) + + 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}" From 9c2b9463a9ae39734647f9457a654f8fe33d1031 Mon Sep 17 00:00:00 2001 From: Andrew Morgan Date: Wed, 14 Jan 2026 15:47:21 +0000 Subject: [PATCH 03/24] Wire the new `MasAdminClient` in If `mas` is defined in the config, then use it to create users instead of Synapse's module API. --- .../guest_registration_servlet.py | 27 +++++++++++++++---- 1 file changed, 22 insertions(+), 5 deletions(-) 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..f60ce87b1f 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 @@ -17,9 +17,11 @@ from synapse.module_api import ( 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 +37,12 @@ class GuestRegistrationServlet(DirectServeJsonResource): self, config: GuestModuleConfig, api: ModuleApi, + mas_admin_client: MasAdminClient | None = None, ): super().__init__() self._api = api self._config = config + self._mas_admin_client = mas_admin_client 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 @@ -51,6 +55,8 @@ class GuestRegistrationServlet(DirectServeJsonResource): 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"} + + displayname = displayname.strip() # make sure the regex is unique for _ in range(10): @@ -67,14 +73,25 @@ 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 + ) + else: + logger.info("Registering MAS guest user with username '%s'", localpart) + await self._mas_admin_client.create_user(localpart) + + user_id = self._api.get_qualified_user_id(localpart) + + await self._api.set_displayname( + UserID.from_string(user_id), + displayname + self._config.display_name_suffix, + ) device_id, access_token, _, _ = await self._api.register_device(user_id) - logger.debug("Registered user %s", user_id) + logger.debug("Registered user '%s'", user_id) res = { "userId": user_id, From 801d28f3ca4a581966510e4d343f0c9f19704d6e Mon Sep 17 00:00:00 2001 From: Andrew Morgan Date: Wed, 14 Jan 2026 16:38:35 +0000 Subject: [PATCH 04/24] Create a personal session on MAS In order to get an access token for a user, one needs to create a personal session on MAS. We now do so, and extract the access token and device ID from the response. TODO: We're handing back the MAS user ID as the device ID. Is that correct? --- .../guest_registration_servlet.py | 26 ++++++++-- .../synapse_guest_module/mas_admin_client.py | 50 ++++++++++++++++++- 2 files changed, 71 insertions(+), 5 deletions(-) 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 f60ce87b1f..41e2be9ed6 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 @@ -74,13 +74,20 @@ class GuestRegistrationServlet(DirectServeJsonResource): continue if self._mas_admin_client is None: - logger.info("Registering local Synapse guest user with localpart '%s'", localpart) + 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 + ) else: logger.info("Registering MAS guest user with username '%s'", localpart) - await self._mas_admin_client.create_user(localpart) + mas_user_id = await self._mas_admin_client.create_user(localpart) user_id = self._api.get_qualified_user_id(localpart) @@ -89,7 +96,20 @@ class GuestRegistrationServlet(DirectServeJsonResource): displayname + self._config.display_name_suffix, ) - device_id, access_token, _, _ = await self._api.register_device(user_id) + # 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 = ( + 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 + ) + ) logger.debug("Registered user '%s'", user_id) 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 index 50fd38dae5..286a2e2f12 100644 --- a/modules/restricted-guests/synapse/synapse_guest_module/mas_admin_client.py +++ b/modules/restricted-guests/synapse/synapse_guest_module/mas_admin_client.py @@ -22,15 +22,61 @@ class MasAdminClient: self._admin_api_base_url = config.admin_api_base_url.rstrip("/") self._oauth_base_url = config.oauth_base_url.rstrip("/") - async def create_user(self, username: str) -> None: + 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") - await self._api.http_client.post_json_get_json( + response = await self._api.http_client.post_json_get_json( uri=url, post_json={"username": username}, headers={"Authorization": [f"Bearer {token}"]}, ) + + mas_user_id = 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: int + ) -> tuple[str, str]: + token = await self.request_admin_token() + url = self._build_admin_url("/api/admin/v1/personal-sessions") + + request_body = { + "actor_user_id": mas_user_id, + "expires_in": expires_in, + "scope": "openid urn:matrix:client:api:*", + "human_name": "guest user", + } + + 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") + # TODO: Is this the correct device ID? + device_id = data.get("id") if isinstance(data, dict) else None + + if not isinstance(access_token, str) or len(access_token) == 0: + raise ValueError("MAS session response missing `access_token` field") + if not isinstance(device_id, str) or len(device_id) == 0: + raise ValueError("MAS session response missing device id") + + return device_id, access_token async def request_admin_token(self) -> str: url = self._build_oauth_url("/oauth2/token") From d0e17ae274054734648a5a4b112e23fdb4b612a1 Mon Sep 17 00:00:00 2001 From: Andrew Morgan Date: Wed, 14 Jan 2026 16:59:05 +0000 Subject: [PATCH 05/24] Add the ability to deactivate users in the MAS client As we won't be able to deactivate them using the Synapse module API --- .../synapse/synapse_guest_module/mas_admin_client.py | 11 +++++++++++ 1 file changed, 11 insertions(+) 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 index 286a2e2f12..299481da68 100644 --- a/modules/restricted-guests/synapse/synapse_guest_module/mas_admin_client.py +++ b/modules/restricted-guests/synapse/synapse_guest_module/mas_admin_client.py @@ -78,6 +78,17 @@ class MasAdminClient: 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: url = self._build_oauth_url("/oauth2/token") basic_auth = base64.b64encode( From 9f4ca6f7e6fbfda209f650a701e2b46182951f52 Mon Sep 17 00:00:00 2001 From: Andrew Morgan Date: Wed, 14 Jan 2026 17:00:38 +0000 Subject: [PATCH 06/24] Store registered users in a namespaced table, deactivate upon expiry To deactivate users, we need their `actor_user_id` (MAS-specific). I don't believe there's a way to get this from Synapse. So, we store user's detailed in a namespaced table upon registering them, along with the creation timestamp, and deactivate them once they're considered expired. --- .../synapse_guest_module/guest_module.py | 49 +++++++++++++- .../guest_registration_servlet.py | 27 +++++++- .../synapse_guest_module/guest_user_reaper.py | 64 ++++++++++++++++++- 3 files changed, 136 insertions(+), 4 deletions(-) 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 174c3474f6..3fd463fb98 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, Optional, Tuple, Union from synapse.module_api import ( NOT_SPAM, + LoggingTransaction, ModuleApi, ProfileInfo, UserProfile, @@ -33,12 +35,20 @@ class GuestModule: def __init__(self, config: GuestModuleConfig, api: ModuleApi): self._api = api self._config = config + self._mas_tables_ready: asyncio.Event | None = None 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 + config, api, mas_admin_client, self._mas_tables_ready ) self._api.register_web_resource( "/_synapse/client/register_guest", self.registration_servlet @@ -54,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", @@ -152,6 +164,39 @@ 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, + created_at BIGINT NOT NULL + ) + """, + (), + ) + txn.execute( + """ + CREATE INDEX IF NOT EXISTS guest_module_mas_users_created_at + ON guest_module_mas_users (created_at) + """, + (), + ) + 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 41e2be9ed6..a0ac7fceae 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,12 +7,15 @@ # 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, @@ -38,11 +41,13 @@ class GuestRegistrationServlet(DirectServeJsonResource): 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 @@ -55,7 +60,7 @@ class GuestRegistrationServlet(DirectServeJsonResource): 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"} - + displayname = displayname.strip() # make sure the regex is unique @@ -96,6 +101,8 @@ class GuestRegistrationServlet(DirectServeJsonResource): displayname + self._config.display_name_suffix, ) + await self._store_mas_user(mas_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 @@ -123,3 +130,21 @@ 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, created_at: int) -> None: + 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, + "created_at": created_at, + }, + ) + + 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..2c75ccec34 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,55 @@ 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: + 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 < ? + """, + (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. From d45fd845b3e10bad90b07eb3912f2f36a4aca11f Mon Sep 17 00:00:00 2001 From: Andrew Morgan Date: Fri, 16 Jan 2026 16:37:11 +0000 Subject: [PATCH 07/24] Add `client_secret_filepath` option To allow supplying the `client_secret` in a file, instead of directly in the configuration. I've found that ESS prefer this method in the past, so the entire config does not need to be a secret. --- modules/restricted-guests/synapse/README.md | 3 ++ .../synapse/synapse_guest_module/config.py | 4 +-- .../synapse_guest_module/guest_module.py | 29 ++++++++++++++-- .../synapse_guest_module/mas_admin_client.py | 34 +++++++++++++++++-- 4 files changed, 62 insertions(+), 8 deletions(-) diff --git a/modules/restricted-guests/synapse/README.md b/modules/restricted-guests/synapse/README.md index e3ed2ccac8..c03c874865 100644 --- a/modules/restricted-guests/synapse/README.md +++ b/modules/restricted-guests/synapse/README.md @@ -51,6 +51,7 @@ API](https://element-hq.github.io/matrix-authentication-service/topics/admin-api - `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: @@ -70,6 +71,8 @@ modules: # 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 diff --git a/modules/restricted-guests/synapse/synapse_guest_module/config.py b/modules/restricted-guests/synapse/synapse_guest_module/config.py index 5e54f2f642..7841d1433e 100644 --- a/modules/restricted-guests/synapse/synapse_guest_module/config.py +++ b/modules/restricted-guests/synapse/synapse_guest_module/config.py @@ -17,8 +17,8 @@ class MasConfig: admin_api_base_url: str oauth_base_url: str client_id: str - # TODO: Add a filepath option for the secret as well. - client_secret: str + client_secret: Optional[str] = None + client_secret_filepath: Optional[str] = None @attr.s(frozen=True, auto_attribs=True) 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 3fd463fb98..525482294b 100644 --- a/modules/restricted-guests/synapse/synapse_guest_module/guest_module.py +++ b/modules/restricted-guests/synapse/synapse_guest_module/guest_module.py @@ -120,14 +120,37 @@ class GuestModule: raise ConfigError("Config option 'mas.client_id' is required and must be a string") client_secret = mas_config.get("client_secret") - if not isinstance(client_secret, str) or len(client_secret.strip()) == 0: - raise ConfigError("Config option 'mas.client_secret' is required and must be a string") + 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.strip(), + client_secret, + client_secret_filepath, ) return GuestModuleConfig( 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 index 299481da68..a650450531 100644 --- a/modules/restricted-guests/synapse/synapse_guest_module/mas_admin_client.py +++ b/modules/restricted-guests/synapse/synapse_guest_module/mas_admin_client.py @@ -18,9 +18,10 @@ class MasAdminClient: def __init__(self, api: ModuleApi, config: MasConfig): self._api = api self._config = config - # Strip trailing any slashes if present + # 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. @@ -56,7 +57,7 @@ class MasAdminClient: "actor_user_id": mas_user_id, "expires_in": expires_in, "scope": "openid urn:matrix:client:api:*", - "human_name": "guest user", + "human_name": "guest session", } response = await self._api.http_client.post_json_get_json( @@ -92,7 +93,7 @@ class MasAdminClient: async def request_admin_token(self) -> str: url = self._build_oauth_url("/oauth2/token") basic_auth = base64.b64encode( - f"{self._config.client_id}:{self._config.client_secret}".encode("utf-8") + f"{self._config.client_id}:{self._client_secret}".encode("utf-8") ).decode("ascii") headers = { "Authorization": [f"Basic {basic_auth}"], @@ -108,6 +109,33 @@ class MasAdminClient: 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 async def _post_urlencoded_get_json( self, url: str, data: Dict[str, str], headers: Dict[str, Any] From 2aa16a8222ca3636810b71450699aba05dd81895 Mon Sep 17 00:00:00 2001 From: Andrew Morgan Date: Fri, 16 Jan 2026 16:49:12 +0000 Subject: [PATCH 08/24] lint --- modules/restricted-guests/synapse/README.md | 28 ++++++++--------- .../synapse_guest_module/guest_module.py | 31 +++++++++++++------ .../guest_registration_servlet.py | 17 +++++----- .../synapse_guest_module/guest_user_reaper.py | 6 ++++ .../synapse_guest_module/mas_admin_client.py | 20 ++++++------ 5 files changed, 58 insertions(+), 44 deletions(-) diff --git a/modules/restricted-guests/synapse/README.md b/modules/restricted-guests/synapse/README.md index c03c874865..4cf5c743ce 100644 --- a/modules/restricted-guests/synapse/README.md +++ b/modules/restricted-guests/synapse/README.md @@ -47,11 +47,11 @@ 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`. + - `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: @@ -81,19 +81,19 @@ Then, add the following to your MAS config file: ```yaml policy: - data: - admin_clients: - - 000000000000000000000G0EST + 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 + # 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/synapse_guest_module/guest_module.py b/modules/restricted-guests/synapse/synapse_guest_module/guest_module.py index 525482294b..281cf9dd6b 100644 --- a/modules/restricted-guests/synapse/synapse_guest_module/guest_module.py +++ b/modules/restricted-guests/synapse/synapse_guest_module/guest_module.py @@ -24,9 +24,9 @@ from synapse.module_api.errors import ConfigError from synapse.types import UserID from synapse_guest_module.config import GuestModuleConfig, MasConfig -from synapse_guest_module.mas_admin_client import MasAdminClient 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__) @@ -106,22 +106,30 @@ class GuestModule: 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") + 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" - ) + 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") + 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: + if ( + not isinstance(client_secret, str) + or len(client_secret.strip()) == 0 + ): raise ConfigError( "Config option 'mas.client_secret' must be a string" ) @@ -129,7 +137,10 @@ class GuestModule: 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: + 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" ) @@ -139,7 +150,7 @@ class GuestModule: 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" 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 a0ac7fceae..4eb6e0b607 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 @@ -87,9 +87,7 @@ class GuestRegistrationServlet(DirectServeJsonResource): 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 username '%s'", localpart) mas_user_id = await self._mas_admin_client.create_user(localpart) @@ -112,10 +110,11 @@ class GuestRegistrationServlet(DirectServeJsonResource): 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 - ) + ( + device_id, + access_token, + ) = await self._mas_admin_client.create_personal_session( + mas_user_id, expires_in ) logger.debug("Registered user '%s'", user_id) @@ -145,6 +144,4 @@ class GuestRegistrationServlet(DirectServeJsonResource): }, ) - await self._api.run_db_interaction( - "guest_module_store_mas_user", store_user - ) + 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 2c75ccec34..dd2163b428 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 @@ -106,6 +106,12 @@ class GuestUserReaper: 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() 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 index a650450531..50ff0e2480 100644 --- a/modules/restricted-guests/synapse/synapse_guest_module/mas_admin_client.py +++ b/modules/restricted-guests/synapse/synapse_guest_module/mas_admin_client.py @@ -25,10 +25,10 @@ class MasAdminClient: 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. """ @@ -40,8 +40,8 @@ class MasAdminClient: post_json={"username": username}, headers={"Authorization": [f"Bearer {token}"]}, ) - - mas_user_id = response.get("data", {}).get("id") + + 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") @@ -109,7 +109,7 @@ class MasAdminClient: 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: @@ -141,13 +141,13 @@ class MasAdminClient: self, url: str, data: Dict[str, str], headers: Dict[str, Any] ) -> Any: http_client = self._api.http_client - post_urlencoded = getattr(http_client, "post_urlencoded_get_json", None) - if callable(post_urlencoded): + post_urlencoded: Optional[Awaitable[Any]] = getattr( + http_client, "post_urlencoded_get_json", None + ) + if post_urlencoded is not None and callable(post_urlencoded): return await post_urlencoded(url, data, headers=headers) - logger.debug( - "MAS client falling back to post_json_get_json for %s", url - ) + logger.debug("MAS client falling back to post_json_get_json for %s", url) return await http_client.post_json_get_json( uri=url, post_json=data, headers=headers ) From 55ca2be0f6758f18c9758f83cf4612bfe9b0edda Mon Sep 17 00:00:00 2001 From: Andrew Morgan Date: Fri, 16 Jan 2026 18:05:26 +0000 Subject: [PATCH 09/24] Update unit tests to check MAS config, deactivation works --- .../synapse_guest_module/mas_admin_client.py | 2 +- .../synapse/tests/__init__.py | 24 ++++++++--- .../synapse/tests/test_guest_module.py | 34 ++++++++++++++- .../synapse/tests/test_guest_user_reaper.py | 43 +++++++++++++++++++ 4 files changed, 93 insertions(+), 10 deletions(-) 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 index 50ff0e2480..70e9e1b0f8 100644 --- a/modules/restricted-guests/synapse/synapse_guest_module/mas_admin_client.py +++ b/modules/restricted-guests/synapse/synapse_guest_module/mas_admin_client.py @@ -5,7 +5,7 @@ import base64 import logging -from typing import Any, Dict +from typing import Any, Awaitable, Dict, Optional from synapse.module_api import ModuleApi diff --git a/modules/restricted-guests/synapse/tests/__init__.py b/modules/restricted-guests/synapse/tests/__init__.py index 04d9c250b8..d6bc7b5f3f 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,14 +113,19 @@ 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 @@ -127,3 +134,6 @@ def _setup_db(conn: sqlite3.Connection) -> None: conn.execute( "CREATE TABLE users(name text, deactivated smallint, creation_ts bigint)" ) + conn.execute( + "CREATE TABLE guest_module_mas_users(mas_user_id text, created_at 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..01605528a0 100644 --- a/modules/restricted-guests/synapse/tests/test_guest_module.py +++ b/modules/restricted-guests/synapse/tests/test_guest_module.py @@ -12,7 +12,7 @@ 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 @@ -28,10 +28,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 +49,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, + ), ), ) 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..06815e1427 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", 0], + ["mas-active", 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 FROM guest_module_mas_users" + ).fetchall() + self.assertEqual(remaining_users, [("mas-active",)]) From 41922415badb87dd993f9ec006cbaf68a30748be Mon Sep 17 00:00:00 2001 From: Andrew Morgan Date: Fri, 16 Jan 2026 19:20:13 +0000 Subject: [PATCH 10/24] Update tests to run with MAS module variant We now run the standard battery of tests with a MAS-enabled module. Some minimal adjustment was needed to check MAS-specific outputs. --- modules/restricted-guests/synapse/setup.cfg | 1 + .../synapse/tests/__init__.py | 29 ++++++- .../synapse/tests/test_guest_module.py | 37 ++++++--- .../tests/test_guest_registration_servlet.py | 83 +++++++++++++++---- 4 files changed, 119 insertions(+), 31 deletions(-) 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/tests/__init__.py b/modules/restricted-guests/synapse/tests/__init__.py index d6bc7b5f3f..d79a60a891 100644 --- a/modules/restricted-guests/synapse/tests/__init__.py +++ b/modules/restricted-guests/synapse/tests/__init__.py @@ -9,8 +9,8 @@ import sqlite3 from asyncio import Future -from typing import Any, Awaitable, Callable, Dict, Tuple, TypeVar -from unittest.mock import Mock +from typing import Any, Awaitable, Callable, Dict, List, Tuple, TypeVar +from unittest.mock import AsyncMock, Mock from synapse.http.client import SimpleHttpClient from synapse.module_api import ModuleApi @@ -81,6 +81,20 @@ def make_awaitable(result: TV) -> Awaitable[TV]: return future +def set_async_return_value(target: Any, value: Any) -> None: + if isinstance(target, AsyncMock): + target.return_value = value + else: + target.return_value = make_awaitable(value) + + +def set_async_side_effect(target: Any, values: List[Any]) -> None: + if isinstance(target, AsyncMock): + target.side_effect = values + else: + target.side_effect = [make_awaitable(value) for value in values] + + def get_qualified_user_id(username: str) -> str: return f"@{username}:matrix.local" @@ -129,6 +143,17 @@ def create_module( 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( diff --git a/modules/restricted-guests/synapse/tests/test_guest_module.py b/modules/restricted-guests/synapse/tests/test_guest_module.py index 01605528a0..10e51a178e 100644 --- a/modules/restricted-guests/synapse/tests/test_guest_module.py +++ b/modules/restricted-guests/synapse/tests/test_guest_module.py @@ -7,17 +7,20 @@ # 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, 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({}) @@ -121,8 +124,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", @@ -134,7 +149,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", @@ -146,7 +161,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", @@ -161,7 +176,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", @@ -170,7 +185,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", @@ -179,7 +194,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", @@ -190,7 +205,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", @@ -201,7 +216,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( @@ -214,7 +229,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..402b52020e 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,38 @@ # . 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 synapse_guest_module import GuestModule -from tests import create_module, make_awaitable +from tests import ( + SQLiteStore, + create_module, + make_awaitable, + mas_config_override, + set_async_return_value, + set_async_side_effect, +) +@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 +52,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 +65,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 +82,52 @@ 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: + set_async_return_value( + module_api.http_client.post_urlencoded_get_json, + {"access_token": "mas_admin_token"}, + ) + set_async_side_effect( + module_api.http_client.post_json_get_json, + [ + {"data": {"id": "mas-user-id"}}, + { + "data": { + "id": "MASDEVICE", + "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 - }, - ) - - module_api.register_user.assert_called_with(ANY, "My Name (Guest)") + 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": "MASDEVICE", + "homeserverUrl": "https://matrix.local:1234/", + # "userId" was already checked by self.assertRegex and was removed from the object + }, + ) From b8fda8acdfec54e0b09fafcecece956642bed916 Mon Sep 17 00:00:00 2001 From: Andrew Morgan Date: Mon, 19 Jan 2026 13:04:31 +0000 Subject: [PATCH 11/24] lint --- modules/restricted-guests/synapse/tests/test_guest_module.py | 1 + .../synapse/tests/test_guest_registration_servlet.py | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/modules/restricted-guests/synapse/tests/test_guest_module.py b/modules/restricted-guests/synapse/tests/test_guest_module.py index 10e51a178e..714e1fd918 100644 --- a/modules/restricted-guests/synapse/tests/test_guest_module.py +++ b/modules/restricted-guests/synapse/tests/test_guest_module.py @@ -9,6 +9,7 @@ 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 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 402b52020e..81390efa29 100644 --- a/modules/restricted-guests/synapse/tests/test_guest_registration_servlet.py +++ b/modules/restricted-guests/synapse/tests/test_guest_registration_servlet.py @@ -15,8 +15,8 @@ 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 synapse_guest_module import GuestModule +from synapse_guest_module import GuestModule from tests import ( SQLiteStore, create_module, From 3f53333448a1e24de10a44a35b1c9875c3af9575 Mon Sep 17 00:00:00 2001 From: Andrew Morgan Date: Mon, 19 Jan 2026 13:56:57 +0000 Subject: [PATCH 12/24] Return the correct device ID Turns out the client needs to generate it, and pass it as a scope. Clever! --- .../synapse_guest_module/mas_admin_client.py | 28 +++++++++++++++---- 1 file changed, 23 insertions(+), 5 deletions(-) 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 index 70e9e1b0f8..2535790ad2 100644 --- a/modules/restricted-guests/synapse/synapse_guest_module/mas_admin_client.py +++ b/modules/restricted-guests/synapse/synapse_guest_module/mas_admin_client.py @@ -5,6 +5,8 @@ import base64 import logging +import secrets +import string from typing import Any, Awaitable, Dict, Optional from synapse.module_api import ModuleApi @@ -53,10 +55,11 @@ class MasAdminClient: 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, - "scope": "openid urn:matrix:client:api:*", + "scope": f"openid urn:matrix:client:api:* urn:matrix:client:device:{device_id}", "human_name": "guest session", } @@ -69,13 +72,11 @@ class MasAdminClient: data = response.get("data", {}) attributes = data.get("attributes", {}) if isinstance(data, dict) else {} access_token = attributes.get("access_token") - # TODO: Is this the correct device ID? - device_id = data.get("id") if isinstance(data, dict) else None + + print(response) if not isinstance(access_token, str) or len(access_token) == 0: raise ValueError("MAS session response missing `access_token` field") - if not isinstance(device_id, str) or len(device_id) == 0: - raise ValueError("MAS session response missing device id") return device_id, access_token @@ -137,6 +138,23 @@ class MasAdminClient: 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(secrets.choice(alphabet) for _ in range(length)) + async def _post_urlencoded_get_json( self, url: str, data: Dict[str, str], headers: Dict[str, Any] ) -> Any: From 2a9c384213d21ad7b272bc7b495a34ac3dc50c51 Mon Sep 17 00:00:00 2001 From: Andrew Morgan Date: Mon, 19 Jan 2026 14:20:02 +0000 Subject: [PATCH 13/24] Update tests for randomly generated device IDs Pin the device ID in tests by mocking the generation function. And test said generation function. --- .../tests/test_guest_registration_servlet.py | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) 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 81390efa29..ca72d17964 100644 --- a/modules/restricted-guests/synapse/tests/test_guest_registration_servlet.py +++ b/modules/restricted-guests/synapse/tests/test_guest_registration_servlet.py @@ -17,6 +17,7 @@ from twisted.web.server import Request from twisted.web.test.requesthelper import DummyRequest from synapse_guest_module import GuestModule +from synapse_guest_module.mas_admin_client import MasAdminClient from tests import ( SQLiteStore, create_module, @@ -88,6 +89,9 @@ class GuestUserReaperTest(aiounittest.AsyncTestCase): 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" + ) set_async_return_value( module_api.http_client.post_urlencoded_get_json, {"access_token": "mas_admin_token"}, @@ -98,7 +102,7 @@ class GuestUserReaperTest(aiounittest.AsyncTestCase): {"data": {"id": "mas-user-id"}}, { "data": { - "id": "MASDEVICE", + "id": "MASDEVICE123", "attributes": {"access_token": "mas_access_token"}, } }, @@ -126,8 +130,16 @@ class GuestUserReaperTest(aiounittest.AsyncTestCase): response, { "accessToken": "mas_access_token", - "deviceId": "MASDEVICE", + "deviceId": "MASDEVICE123", "homeserverUrl": "https://matrix.local:1234/", # "userId" was already checked by self.assertRegex and was removed from the object }, ) + + +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-]+$") From f88a280b8b5cd3fa467a9a56a11c3e21e26b6f10 Mon Sep 17 00:00:00 2001 From: Andrew Morgan Date: Fri, 23 Jan 2026 13:52:19 +0000 Subject: [PATCH 14/24] Correct typo in comment --- .../synapse/synapse_guest_module/guest_registration_servlet.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 4eb6e0b607..9835ab7e10 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 @@ -63,8 +63,9 @@ class GuestRegistrationServlet(DirectServeJsonResource): displayname = displayname.strip() - # make sure the regex is unique + # 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) From 514c6957f7ef15658a4179abd1cecbfbea22b051 Mon Sep 17 00:00:00 2001 From: Andrew Morgan Date: Fri, 23 Jan 2026 14:02:00 +0000 Subject: [PATCH 15/24] Log MXID and MAS ID when registering guest user --- .../synapse_guest_module/guest_registration_servlet.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) 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 9835ab7e10..fcf1469161 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 @@ -90,11 +90,15 @@ class GuestRegistrationServlet(DirectServeJsonResource): device_id, access_token, _, _ = await self._api.register_device(user_id) else: - logger.info("Registering MAS guest user with username '%s'", localpart) - mas_user_id = await self._mas_admin_client.create_user(localpart) + logger.info("Registering MAS guest user with localpart '%s'", localpart) + # 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, From 2e71abdfda74d8250dad25de4ea9e69088408bbe Mon Sep 17 00:00:00 2001 From: Andrew Morgan Date: Fri, 23 Jan 2026 14:38:19 +0000 Subject: [PATCH 16/24] Add `user_id` field to `guest_module_mas_users` Currently unused, but may be useful in future. --- .../synapse/synapse_guest_module/guest_module.py | 1 + .../synapse_guest_module/guest_registration_servlet.py | 5 +++-- modules/restricted-guests/synapse/tests/__init__.py | 2 +- .../synapse/tests/test_guest_user_reaper.py | 10 +++++----- 4 files changed, 10 insertions(+), 8 deletions(-) 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 281cf9dd6b..7f53f5052e 100644 --- a/modules/restricted-guests/synapse/synapse_guest_module/guest_module.py +++ b/modules/restricted-guests/synapse/synapse_guest_module/guest_module.py @@ -218,6 +218,7 @@ class GuestModule: """ CREATE TABLE IF NOT EXISTS guest_module_mas_users ( mas_user_id TEXT PRIMARY KEY, + user_id TEXT, created_at BIGINT NOT NULL ) """, 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 fcf1469161..41257e5726 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 @@ -104,7 +104,7 @@ class GuestRegistrationServlet(DirectServeJsonResource): displayname + self._config.display_name_suffix, ) - await self._store_mas_user(mas_user_id, int(time.time())) + await self._store_mas_user(mas_user_id, user_id, int(time.time())) # Determine how long to keep the access token valid for. # @@ -135,7 +135,7 @@ class GuestRegistrationServlet(DirectServeJsonResource): return 500, {"msg": "Internal error: Could not find a free username"} - async def _store_mas_user(self, mas_user_id: str, created_at: int) -> None: + async def _store_mas_user(self, mas_user_id: str, user_id: str, created_at: int) -> None: if self._mas_tables_ready is not None: await self._mas_tables_ready.wait() @@ -145,6 +145,7 @@ class GuestRegistrationServlet(DirectServeJsonResource): table="guest_module_mas_users", values={ "mas_user_id": mas_user_id, + "user_id": user_id, "created_at": created_at, }, ) diff --git a/modules/restricted-guests/synapse/tests/__init__.py b/modules/restricted-guests/synapse/tests/__init__.py index d79a60a891..b97dd1c45b 100644 --- a/modules/restricted-guests/synapse/tests/__init__.py +++ b/modules/restricted-guests/synapse/tests/__init__.py @@ -160,5 +160,5 @@ def _setup_db(conn: sqlite3.Connection) -> None: "CREATE TABLE users(name text, deactivated smallint, creation_ts bigint)" ) conn.execute( - "CREATE TABLE guest_module_mas_users(mas_user_id text, created_at bigint)" + "CREATE TABLE guest_module_mas_users(mas_user_id text, user_id text, created_at bigint)" ) 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 06815e1427..960e3f999f 100644 --- a/modules/restricted-guests/synapse/tests/test_guest_user_reaper.py +++ b/modules/restricted-guests/synapse/tests/test_guest_user_reaper.py @@ -136,10 +136,10 @@ class GuestUserReaperTest(aiounittest.AsyncTestCase): now = int(time.time()) store.conn.executemany( - "INSERT INTO guest_module_mas_users VALUES (?, ?)", + "INSERT INTO guest_module_mas_users VALUES (?, ?, ?)", [ - ["mas-old-1", 0], - ["mas-active", now], + ["mas-old-1", "@old-1:localhost", 0], + ["mas-active", "@active:localhost", now], ], ) @@ -161,6 +161,6 @@ class GuestUserReaperTest(aiounittest.AsyncTestCase): ) remaining_users = store.conn.execute( - "SELECT mas_user_id FROM guest_module_mas_users" + "SELECT mas_user_id, user_id FROM guest_module_mas_users" ).fetchall() - self.assertEqual(remaining_users, [("mas-active",)]) + self.assertEqual(remaining_users, [("mas-active", "@active:localhost")]) From e0304c627bf01ce908dca56e97f0b870202355f3 Mon Sep 17 00:00:00 2001 From: Andrew Morgan Date: Fri, 23 Jan 2026 14:41:06 +0000 Subject: [PATCH 17/24] `created_at` -> `created_at_sec` --- .../synapse/synapse_guest_module/guest_module.py | 6 +++--- .../guest_registration_servlet.py | 11 +++++++++-- .../synapse/synapse_guest_module/guest_user_reaper.py | 2 +- modules/restricted-guests/synapse/tests/__init__.py | 2 +- 4 files changed, 14 insertions(+), 7 deletions(-) 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 7f53f5052e..93092a9be2 100644 --- a/modules/restricted-guests/synapse/synapse_guest_module/guest_module.py +++ b/modules/restricted-guests/synapse/synapse_guest_module/guest_module.py @@ -219,15 +219,15 @@ class GuestModule: CREATE TABLE IF NOT EXISTS guest_module_mas_users ( mas_user_id TEXT PRIMARY KEY, user_id TEXT, - created_at BIGINT NOT NULL + created_at_sec BIGINT NOT NULL ) """, (), ) txn.execute( """ - CREATE INDEX IF NOT EXISTS guest_module_mas_users_created_at - ON guest_module_mas_users (created_at) + CREATE INDEX IF NOT EXISTS guest_module_mas_users_created_at_sec + ON guest_module_mas_users (created_at_sec) """, (), ) 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 41257e5726..0d5dd51b7c 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 @@ -135,7 +135,14 @@ class GuestRegistrationServlet(DirectServeJsonResource): 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: int) -> None: + 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() @@ -146,7 +153,7 @@ class GuestRegistrationServlet(DirectServeJsonResource): values={ "mas_user_id": mas_user_id, "user_id": user_id, - "created_at": created_at, + "created_at_sec": created_at_sec, }, ) 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 dd2163b428..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 @@ -121,7 +121,7 @@ class GuestUserReaper: """ SELECT mas_user_id FROM guest_module_mas_users - WHERE created_at < ? + WHERE created_at_sec < ? """, (expire_ts_seconds,), ) diff --git a/modules/restricted-guests/synapse/tests/__init__.py b/modules/restricted-guests/synapse/tests/__init__.py index b97dd1c45b..f62fc2cbf4 100644 --- a/modules/restricted-guests/synapse/tests/__init__.py +++ b/modules/restricted-guests/synapse/tests/__init__.py @@ -160,5 +160,5 @@ def _setup_db(conn: sqlite3.Connection) -> None: "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 bigint)" + "CREATE TABLE guest_module_mas_users(mas_user_id text, user_id text, created_at_sec bigint)" ) From a4173bb1dd72aee3c9b7f005243fae96ee252256 Mon Sep 17 00:00:00 2001 From: Andrew Morgan Date: Fri, 23 Jan 2026 14:55:17 +0000 Subject: [PATCH 18/24] `expires_in` -> `expires_in_sec` + docstring --- .../guest_registration_servlet.py | 4 ++-- .../synapse_guest_module/mas_admin_client.py | 15 +++++++++++---- 2 files changed, 13 insertions(+), 6 deletions(-) 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 0d5dd51b7c..3300204480 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 @@ -110,7 +110,7 @@ class GuestRegistrationServlet(DirectServeJsonResource): # # If a user reaper is enabled, just have the token expire after # the configured period. - expires_in = ( + expires_in_sec = ( self._config.user_expiration_seconds if self._config.enable_user_reaper else 0 @@ -119,7 +119,7 @@ class GuestRegistrationServlet(DirectServeJsonResource): device_id, access_token, ) = await self._mas_admin_client.create_personal_session( - mas_user_id, expires_in + mas_user_id, expires_in_sec ) logger.debug("Registered user '%s'", user_id) 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 index 2535790ad2..a010371204 100644 --- a/modules/restricted-guests/synapse/synapse_guest_module/mas_admin_client.py +++ b/modules/restricted-guests/synapse/synapse_guest_module/mas_admin_client.py @@ -50,15 +50,24 @@ class MasAdminClient: return mas_user_id async def create_personal_session( - self, mas_user_id: str, expires_in: int + 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, + "expires_in": expires_in_sec, "scope": f"openid urn:matrix:client:api:* urn:matrix:client:device:{device_id}", "human_name": "guest session", } @@ -73,8 +82,6 @@ class MasAdminClient: attributes = data.get("attributes", {}) if isinstance(data, dict) else {} access_token = attributes.get("access_token") - print(response) - if not isinstance(access_token, str) or len(access_token) == 0: raise ValueError("MAS session response missing `access_token` field") From ecd25faec824285a21c53f351a8c0fd1731a4bd8 Mon Sep 17 00:00:00 2001 From: Andrew Morgan Date: Fri, 23 Jan 2026 15:16:39 +0000 Subject: [PATCH 19/24] Add docstring for `request_admin_token` --- .../synapse_guest_module/mas_admin_client.py | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) 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 index a010371204..60c03aa417 100644 --- a/modules/restricted-guests/synapse/synapse_guest_module/mas_admin_client.py +++ b/modules/restricted-guests/synapse/synapse_guest_module/mas_admin_client.py @@ -99,6 +99,17 @@ class MasAdminClient: ) 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") @@ -165,6 +176,20 @@ class MasAdminClient: async def _post_urlencoded_get_json( self, url: str, data: Dict[str, str], headers: Dict[str, Any] ) -> Any: + """Helper to POST JSON data and get JSON response. + + Args: + url: The URL to POST to. + data: The form data to POST. + headers: Additional headers to include in the request. + + Returns: + The JSON response from the server. + + Raises: + HttpResponseException: On a non-2xx HTTP response. + ValueError: if the response was not JSON. + """ http_client = self._api.http_client post_urlencoded: Optional[Awaitable[Any]] = getattr( http_client, "post_urlencoded_get_json", None From 7abb63f1f205d477bb1af30199d0ad916f3fd8b9 Mon Sep 17 00:00:00 2001 From: Andrew Morgan Date: Fri, 23 Jan 2026 15:18:55 +0000 Subject: [PATCH 20/24] Don't be cryptographically random! That's what you get for generating a random string function. --- .../synapse/synapse_guest_module/mas_admin_client.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 index 60c03aa417..b7166887f5 100644 --- a/modules/restricted-guests/synapse/synapse_guest_module/mas_admin_client.py +++ b/modules/restricted-guests/synapse/synapse_guest_module/mas_admin_client.py @@ -5,7 +5,7 @@ import base64 import logging -import secrets +import random import string from typing import Any, Awaitable, Dict, Optional @@ -171,7 +171,7 @@ class MasAdminClient: """ length = 16 alphabet = string.ascii_letters + string.digits + "-" - return "".join(secrets.choice(alphabet) for _ in range(length)) + return "".join(random.choices(alphabet, k=length)) async def _post_urlencoded_get_json( self, url: str, data: Dict[str, str], headers: Dict[str, Any] From c6dabf052b0a3bd2a4c112d34006f83dda3adc97 Mon Sep 17 00:00:00 2001 From: Andrew Morgan Date: Fri, 23 Jan 2026 16:36:59 +0000 Subject: [PATCH 21/24] Remove fallback wrapper for `post_urlencoded_get_json` --- .../synapse_guest_module/mas_admin_client.py | 32 +------------------ 1 file changed, 1 insertion(+), 31 deletions(-) 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 index b7166887f5..1c7f9d894c 100644 --- a/modules/restricted-guests/synapse/synapse_guest_module/mas_admin_client.py +++ b/modules/restricted-guests/synapse/synapse_guest_module/mas_admin_client.py @@ -7,7 +7,6 @@ import base64 import logging import random import string -from typing import Any, Awaitable, Dict, Optional from synapse.module_api import ModuleApi @@ -123,7 +122,7 @@ class MasAdminClient: "scope": "urn:mas:admin", } - response = await self._post_urlencoded_get_json(url, data, headers) + 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") @@ -173,35 +172,6 @@ class MasAdminClient: alphabet = string.ascii_letters + string.digits + "-" return "".join(random.choices(alphabet, k=length)) - async def _post_urlencoded_get_json( - self, url: str, data: Dict[str, str], headers: Dict[str, Any] - ) -> Any: - """Helper to POST JSON data and get JSON response. - - Args: - url: The URL to POST to. - data: The form data to POST. - headers: Additional headers to include in the request. - - Returns: - The JSON response from the server. - - Raises: - HttpResponseException: On a non-2xx HTTP response. - ValueError: if the response was not JSON. - """ - http_client = self._api.http_client - post_urlencoded: Optional[Awaitable[Any]] = getattr( - http_client, "post_urlencoded_get_json", None - ) - if post_urlencoded is not None and callable(post_urlencoded): - return await post_urlencoded(url, data, headers=headers) - - logger.debug("MAS client falling back to post_json_get_json for %s", url) - return await http_client.post_json_get_json( - uri=url, post_json=data, headers=headers - ) - def _build_admin_url(self, path: str) -> str: if not path.startswith("/"): path = "/" + path From 85ef24f9179282ff6e8e3a1882d119739fe70c50 Mon Sep 17 00:00:00 2001 From: Andrew Morgan Date: Fri, 23 Jan 2026 16:49:12 +0000 Subject: [PATCH 22/24] Remove test runtime checks for AsyncMock vs. Mock Turns out they're always AsyncMock. --- .../synapse/tests/__init__.py | 18 ++--------- .../tests/test_guest_registration_servlet.py | 30 +++++++++---------- 2 files changed, 16 insertions(+), 32 deletions(-) diff --git a/modules/restricted-guests/synapse/tests/__init__.py b/modules/restricted-guests/synapse/tests/__init__.py index f62fc2cbf4..4f990b54f2 100644 --- a/modules/restricted-guests/synapse/tests/__init__.py +++ b/modules/restricted-guests/synapse/tests/__init__.py @@ -9,8 +9,8 @@ import sqlite3 from asyncio import Future -from typing import Any, Awaitable, Callable, Dict, List, Tuple, TypeVar -from unittest.mock import AsyncMock, Mock +from typing import Any, Awaitable, Callable, Dict, Tuple, TypeVar +from unittest.mock import Mock from synapse.http.client import SimpleHttpClient from synapse.module_api import ModuleApi @@ -81,20 +81,6 @@ def make_awaitable(result: TV) -> Awaitable[TV]: return future -def set_async_return_value(target: Any, value: Any) -> None: - if isinstance(target, AsyncMock): - target.return_value = value - else: - target.return_value = make_awaitable(value) - - -def set_async_side_effect(target: Any, values: List[Any]) -> None: - if isinstance(target, AsyncMock): - target.side_effect = values - else: - target.side_effect = [make_awaitable(value) for value in values] - - def get_qualified_user_id(username: str) -> str: return f"@{username}:matrix.local" 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 ca72d17964..f6008cbded 100644 --- a/modules/restricted-guests/synapse/tests/test_guest_registration_servlet.py +++ b/modules/restricted-guests/synapse/tests/test_guest_registration_servlet.py @@ -92,22 +92,20 @@ class GuestUserReaperTest(aiounittest.AsyncTestCase): module.registration_servlet._mas_admin_client._generate_device_id = ( # type: ignore[method-assign,union-attr] lambda: "MASDEVICE123" ) - set_async_return_value( - module_api.http_client.post_urlencoded_get_json, - {"access_token": "mas_admin_token"}, - ) - set_async_side_effect( - module_api.http_client.post_json_get_json, - [ - {"data": {"id": "mas-user-id"}}, - { - "data": { - "id": "MASDEVICE123", - "attributes": {"access_token": "mas_access_token"}, - } - }, - ], - ) + + # `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) From e1b42da345ac2094386913ae147cb297c4d3f20f Mon Sep 17 00:00:00 2001 From: Andrew Morgan Date: Fri, 23 Jan 2026 17:05:56 +0000 Subject: [PATCH 23/24] lint --- .../synapse_guest_module/guest_registration_servlet.py | 10 +++++++--- .../synapse/synapse_guest_module/mas_admin_client.py | 10 ++++++---- .../synapse/tests/test_guest_registration_servlet.py | 9 +-------- 3 files changed, 14 insertions(+), 15 deletions(-) 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 3300204480..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 @@ -97,7 +97,9 @@ class GuestRegistrationServlet(DirectServeJsonResource): # 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}')") + logger.info( + f"Registered guest user: '{user_id}' (MAS ID: '{mas_user_id}')" + ) await self._api.set_displayname( UserID.from_string(user_id), @@ -135,9 +137,11 @@ class GuestRegistrationServlet(DirectServeJsonResource): 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: + 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 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 index 1c7f9d894c..bde247e1ac 100644 --- a/modules/restricted-guests/synapse/synapse_guest_module/mas_admin_client.py +++ b/modules/restricted-guests/synapse/synapse_guest_module/mas_admin_client.py @@ -52,11 +52,11 @@ class MasAdminClient: 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). """ @@ -104,7 +104,7 @@ class MasAdminClient: Returns: The admin access token. - + Raises: ValueError: If the token response is invalid. HttpResponseException: On a non-2xx HTTP response. @@ -122,7 +122,9 @@ class MasAdminClient: "scope": "urn:mas:admin", } - response = await self._api.http_client.post_urlencoded_get_json(url, data, headers) + 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") 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 f6008cbded..b61c6400e0 100644 --- a/modules/restricted-guests/synapse/tests/test_guest_registration_servlet.py +++ b/modules/restricted-guests/synapse/tests/test_guest_registration_servlet.py @@ -18,14 +18,7 @@ from twisted.web.test.requesthelper import DummyRequest 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, - set_async_return_value, - set_async_side_effect, -) +from tests import SQLiteStore, create_module, make_awaitable, mas_config_override @parameterized_class( From ab8a307d5d4f70c57ea5873ddd914bb6d396d561 Mon Sep 17 00:00:00 2001 From: Andrew Morgan Date: Wed, 4 Feb 2026 16:31:19 +0000 Subject: [PATCH 24/24] Make `{mas_,}user_id` DB tables NOT NULL --- .../synapse/synapse_guest_module/guest_module.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 93092a9be2..1160ee6c93 100644 --- a/modules/restricted-guests/synapse/synapse_guest_module/guest_module.py +++ b/modules/restricted-guests/synapse/synapse_guest_module/guest_module.py @@ -217,8 +217,8 @@ class GuestModule: txn.execute( """ CREATE TABLE IF NOT EXISTS guest_module_mas_users ( - mas_user_id TEXT PRIMARY KEY, - user_id TEXT, + mas_user_id TEXT PRIMARY KEY NOT NULL, + user_id TEXT NOT NULL, created_at_sec BIGINT NOT NULL ) """,