Merge pull request #177 from anoadragon453/anoa/synapse_guest_module_mas

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