From 9f4ca6f7e6fbfda209f650a701e2b46182951f52 Mon Sep 17 00:00:00 2001 From: Andrew Morgan Date: Wed, 14 Jan 2026 17:00:38 +0000 Subject: [PATCH] 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.