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-14 17:00:38 +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:
# <http://www.apache.org/licenses/LICENSE-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,
@@ -7,12 +7,15 @@
# Originally licensed under the Apache License, Version 2.0:
# <http://www.apache.org/licenses/LICENSE-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
)
@@ -7,6 +7,7 @@
# Originally licensed under the Apache License, Version 2.0:
# <http://www.apache.org/licenses/LICENSE-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.