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.
This commit is contained in:
Andrew Morgan
2026-01-16 18:05:33 +00:00
parent d0e17ae274
commit 9f4ca6f7e6
3 changed files with 136 additions and 4 deletions
@@ -7,11 +7,13 @@
# Originally licensed under the Apache License, Version 2.0: # Originally licensed under the Apache License, Version 2.0:
# <http://www.apache.org/licenses/LICENSE-2.0>. # <http://www.apache.org/licenses/LICENSE-2.0>.
import asyncio
import logging import logging
from typing import Any, Dict, Literal, Optional, Tuple, Union from typing import Any, Dict, Literal, Optional, Tuple, Union
from synapse.module_api import ( from synapse.module_api import (
NOT_SPAM, NOT_SPAM,
LoggingTransaction,
ModuleApi, ModuleApi,
ProfileInfo, ProfileInfo,
UserProfile, UserProfile,
@@ -33,12 +35,20 @@ class GuestModule:
def __init__(self, config: GuestModuleConfig, api: ModuleApi): def __init__(self, config: GuestModuleConfig, api: ModuleApi):
self._api = api self._api = api
self._config = config self._config = config
self._mas_tables_ready: asyncio.Event | None = None
mas_admin_client = ( mas_admin_client = (
MasAdminClient(api, config.mas) if config.mas is not None else None 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( self.registration_servlet = GuestRegistrationServlet(
config, api, mas_admin_client config, api, mas_admin_client, self._mas_tables_ready
) )
self._api.register_web_resource( self._api.register_web_resource(
"/_synapse/client/register_guest", self.registration_servlet "/_synapse/client/register_guest", self.registration_servlet
@@ -54,7 +64,9 @@ class GuestModule:
) )
# Start the user reaper # 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: if config.enable_user_reaper:
run_as_background_process( run_as_background_process(
"guest_module_reaper_bg_task", "guest_module_reaper_bg_task",
@@ -152,6 +164,39 @@ class GuestModule:
) )
await self._api.set_displayname(user_id_1, guest_display_name) 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( async def callback_user_may_create_room(
self, self,
user_id: str, user_id: str,
@@ -7,12 +7,15 @@
# Originally licensed under the Apache License, Version 2.0: # Originally licensed under the Apache License, Version 2.0:
# <http://www.apache.org/licenses/LICENSE-2.0>. # <http://www.apache.org/licenses/LICENSE-2.0>.
import asyncio
import logging import logging
import secrets import secrets
import string import string
import time
from typing import Any, Dict, Tuple from typing import Any, Dict, Tuple
from synapse.module_api import ( from synapse.module_api import (
DatabasePool,
DirectServeJsonResource, DirectServeJsonResource,
ModuleApi, ModuleApi,
parse_json_object_from_request, parse_json_object_from_request,
@@ -38,11 +41,13 @@ class GuestRegistrationServlet(DirectServeJsonResource):
config: GuestModuleConfig, config: GuestModuleConfig,
api: ModuleApi, api: ModuleApi,
mas_admin_client: MasAdminClient | None = None, mas_admin_client: MasAdminClient | None = None,
mas_tables_ready: asyncio.Event | None = None,
): ):
super().__init__() super().__init__()
self._api = api self._api = api
self._config = config self._config = config
self._mas_admin_client = mas_admin_client 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]]: 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 """On POST requests, generate a new username for a guest, check that it
@@ -96,6 +101,8 @@ class GuestRegistrationServlet(DirectServeJsonResource):
displayname + self._config.display_name_suffix, 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. # Determine how long to keep the access token valid for.
# #
# If a user reaper is enabled, just have the token expire after # If a user reaper is enabled, just have the token expire after
@@ -123,3 +130,21 @@ class GuestRegistrationServlet(DirectServeJsonResource):
return 201, res return 201, res
return 500, {"msg": "Internal error: Could not find a free username"} 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
)
@@ -7,6 +7,7 @@
# Originally licensed under the Apache License, Version 2.0: # Originally licensed under the Apache License, Version 2.0:
# <http://www.apache.org/licenses/LICENSE-2.0>. # <http://www.apache.org/licenses/LICENSE-2.0>.
import asyncio
import logging import logging
import time import time
from typing import List from typing import List
@@ -14,14 +15,23 @@ from typing import List
from synapse.module_api import DatabasePool, LoggingTransaction, ModuleApi from synapse.module_api import DatabasePool, LoggingTransaction, ModuleApi
from synapse_guest_module.config import GuestModuleConfig from synapse_guest_module.config import GuestModuleConfig
from synapse_guest_module.mas_admin_client import MasAdminClient
logger = logging.getLogger("synapse.contrib." + __name__) logger = logging.getLogger("synapse.contrib." + __name__)
class GuestUserReaper: 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._api = api
self._config = config 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" self.reaper_user = f"{config.user_id_prefix}reaper"
async def run(self) -> None: async def run(self) -> None:
@@ -43,6 +53,9 @@ class GuestUserReaper:
"""Deactivate all users that are older than the specified expiration """Deactivate all users that are older than the specified expiration
interval. This uses the admin API to disable the user. 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]: def get_expired_users(txn: LoggingTransaction) -> List[str]:
sql = """ sql = """
@@ -92,6 +105,55 @@ class GuestUserReaper:
except Exception as e: except Exception as e:
logger.error('Failed to delete user "%s": %s', user_id, 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: async def get_admin_token(self) -> str:
"""Create a new admin user in synapse so the module can call the admin """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. api. If no user or login session exists, we create new ones.