Skip to content
Open
Show file tree
Hide file tree
Changes from 9 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 60 additions & 0 deletions modules/restricted-guests/synapse/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ A [pluggable synapse module](https://element-hq.github.io/synapse/latest/modules
3. The temporary users are limited in what they can do (examples: create room, invite users).
4. The temporary users won't be returned by the user directory search results, and the temporary users themselves get an empty user directory: they should not be handed a browsable index of users they have no relationship with.
5. The temporary users are disabled after an expiration timeout (default: `24 hours`).
6. Provides an endpoint that invites people by email to ask to join a room, which MAS emails a personal invite link for (opt-in).

## Compatibility

Expand Down Expand Up @@ -73,6 +74,11 @@ API](https://element-hq.github.io/matrix-authentication-service/topics/admin-api
- `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`.

- `email_invites` - optional configuration for [inviting guests by email](#inviting-guests-by-email). Requires `mas`.
- `enabled` - if true, the module serves `/_synapse/client/invite_guests`. Only enable it on a server with closed registration (see the [deployment checklist](#deployment-checklist)). Default: `false`.
Comment thread
Copilot marked this conversation as resolved.
Resolved
- `max_emails` - the most addresses one request can invite. It can't exceed `per_inviter_per_hour`. Default: `20`.
- `per_inviter_per_hour` - how many addresses one user can invite per hour, on average. Up to this many can go at once, and the allowance then refills over the next hour, so one hour can see nearly twice this many. Each worker counts separately, and the count resets when Synapse restarts. Default: `50`.

Example configuration:

```yaml
Expand All @@ -98,6 +104,11 @@ modules:
client_secret: your-client-secret
# Alternatively, load the secret from a file:
# client_secret_filepath: /run/secrets/mas-client-secret
# Invite guests by email (requires `mas`)
email_invites:
enabled: true
max_emails: 20
per_inviter_per_hour: 50
```

Enable [the Admin API on a MAS
Expand All @@ -121,6 +132,55 @@ clients:
client_secret: your-client-secret
```

## Inviting guests by email

`POST /_synapse/client/invite_guests` invites a list of email addresses to ask to
join a room as guests. It's served when `email_invites.enabled` is set, and needs the
`mas` configuration above. MAS mints the invite codes, sends the emails and registers
the guests. MAS has to support guest invites and have them turned on, otherwise this
endpoint answers 404.

```json
{
"room_id": "!room:example.org",
"emails": ["alice@example.com", "bob@example.com"]
}
```

The caller must be joined to the room and meet its `invite` power level, the same
as for inviting a user directly, and can't be a guest themselves. The room's join rule must be
`knock` (Ask to join), and the room can't be in `rooms_forbidden_to_guests`.
Addresses are deduplicated ignoring case, and each counts once against `max_emails`
and `per_inviter_per_hour`.

Synapse's email-invite controls (`block_non_admin_invites`,
`user_may_send_3pid_invite`, `check_threepid_can_be_invited`,
`rc_third_party_invite`) don't apply to this endpoint.

Each address gets an invite code of its own, pinned to that address and to a fresh
guest localpart, which MAS emails a link for, naming the room and the caller. The
links only ever reach the recipients, so they are not in the response:

```json
{ "scheduled": 2 }
```

A recipient who follows their link signs up as the guest the code names, or signs
in with an existing account, and lands on the room. Nothing exists on this homeserver until they
sign up, and this endpoint doesn't send a room invite.

| Status | When |
| ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 400 | `room_id` or `emails` is missing or malformed, there are more than `max_emails` addresses, or MAS refuses the request, with its message in `msg` |
| 403 | The caller is a guest, isn't joined to the room or is below its `invite` level, or the room is forbidden to guests or its join rule isn't `knock`. For the join rule, the body also has `"reason": "room_not_knockable"`. |
| 404 | MAS has guest invites turned off |
| 429 | The caller is over `per_inviter_per_hour`. The body is Synapse's usual rate-limit error, with `retry_after_ms`. |
| 502 | MAS failed or couldn't be reached |

The user reaper doesn't deactivate guests who signed up from an invite email, it
only knows about the guests `register_guest` creates. They can be manually deactivated
by listing them with MAS' admin API, `GET /api/admin/v1/users?filter[search]=guest-`, and deactivating from there.

## Production installation

The module is not published to a python registry, but we provide a docker container that can be used as an `initContainer` in Kubernetes:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,15 @@ class MasConfig:
client_secret_filepath: Optional[str] = None


@attr.s(frozen=True, auto_attribs=True)
class EmailInvitesConfig:
enabled: bool = False
# 20 covers a team invited at once; 50 an hour bounds the mail one user sends in
# the server's name
max_emails: int = 20
per_inviter_per_hour: int = 50
Comment on lines +30 to +31

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These defaults seem okay 🤷
Basically just numbers to prevent users from generating email / endpoint spam.
Either way, they are configurable to meet a deployments actual usage.



@attr.s(frozen=True, auto_attribs=True)
class GuestModuleConfig:
user_id_prefix: str
Expand All @@ -38,3 +47,4 @@ class GuestModuleConfig:
# directory from guests is meant to prevent.
# Both invites of guests into these rooms and joins by guests are denied.
rooms_forbidden_to_guests: FrozenSet[str] = frozenset()
email_invites: EmailInvitesConfig = EmailInvitesConfig()
Original file line number Diff line number Diff line change
@@ -0,0 +1,213 @@
# Copyright 2026 Element Creations Ltd.
#
# SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
# Please see LICENSE files in the project root for full details.

import json
import logging
import secrets
import string
from typing import Any, Callable, Dict, List, Optional, Tuple

from synapse.api.errors import HttpResponseException
from synapse.api.ratelimiting import Ratelimiter
from synapse.config.ratelimiting import RatelimitSettings
from synapse.event_auth import get_named_level, get_user_power_level
from synapse.http.site import SynapseRequest
from synapse.module_api import (
DirectServeJsonResource,
EventBase,
ModuleApi,
StateMap,
parse_json_object_from_request,
)

from synapse_guest_module.config import GuestModuleConfig
from synapse_guest_module.mas_admin_client import MasAdminClient

logger = logging.getLogger("synapse.contrib." + __name__)


class GuestInviteServlet(DirectServeJsonResource):
"""The `POST /_synapse/client/invite_guests` endpoint invites a list of email
addresses to a room as guests. It requires the `room_id` and `emails`
properties.

Each address is emailed a link to register as one specific guest, which MAS
mints an invite code for and sends out. Nothing exists on this homeserver
until a recipient follows their link: the localparts are only reserved by
the invite codes they are pinned to.
"""

def __init__(
self,
config: GuestModuleConfig,
api: ModuleApi,
mas_admin_client: MasAdminClient,
is_module_guest: Callable[[str], bool],
):
super().__init__()
self._api = api
self._config = config
self._mas_admin_client = mas_admin_client
self._is_module_guest = is_module_guest

# Per worker, and reset on restart
per_hour = config.email_invites.per_inviter_per_hour
self._ratelimiter = Ratelimiter(
store=api._hs.get_datastores().main,
clock=api._hs.get_clock(),
cfg=RatelimitSettings(
key="guest_module_invite_guests",
per_second=per_hour / 3600,
burst_count=per_hour,
),
)

async def _async_render_POST(

Check failure on line 67 in modules/restricted-guests/synapse/synapse_guest_module/guest_invite_servlet.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this function to reduce its Cognitive Complexity from 20 to the 15 allowed.

See more on https://sonarcloud.io/project/issues?id=element-modules&issues=AaEN8OgDLNgfEFUTKPVZ&open=AaEN8OgDLNgfEFUTKPVZ&pullRequest=304
self, request: SynapseRequest
) -> Tuple[int, Dict[str, Any]]:
"""Generate a guest localpart per address, have MAS
mint an invite code for each and email out the links.
Email sending is handled by MAS.
"""

requester = await self._api.get_user_by_req(request)

json_dict = parse_json_object_from_request(request)

room_id = json_dict.get("room_id")
if not isinstance(room_id, str) or not room_id.startswith("!"):
return 400, {"msg": "You must provide a valid 'room_id'"}
Comment thread
erikjohnston marked this conversation as resolved.
Resolved

emails = json_dict.get("emails")
if (
not isinstance(emails, list)
or len(emails) == 0
or not all(
isinstance(email, str) and len(email.strip()) > 0 for email in emails
)
):
return 400, {
"msg": "You must provide 'emails' as a non-empty list of strings"
}

# Keyed ignoring case, keeping the first spelling of each address
unique_emails: Dict[str, str] = {}
for email in emails:
unique_emails.setdefault(email.strip().lower(), email.strip())

max_emails = self._config.email_invites.max_emails
if len(unique_emails) > max_emails:
return 400, {"msg": f"You can invite at most {max_emails} emails at once"}

user_id = requester.user.to_string()
if self._is_module_guest(user_id):
return 403, {"msg": "Guests can't invite guests"}

state = await self._api.get_room_state(
room_id,
[
("m.room.create", ""),
("m.room.join_rules", ""),
("m.room.member", user_id),

Check failure on line 113 in modules/restricted-guests/synapse/synapse_guest_module/guest_invite_servlet.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Define a constant instead of duplicating this literal "m.room.member" 3 times.

See more on https://sonarcloud.io/project/issues?id=element-modules&issues=AaEN8OgDLNgfEFUTKPVY&open=AaEN8OgDLNgfEFUTKPVY&pullRequest=304
("m.room.name", ""),
("m.room.power_levels", ""),
],
)
if not self._may_invite(user_id, state):
return 403, {"msg": "You are not allowed to invite users to this room"}

# An email-invited guest has no Matrix invite, so it gets in by asking to
# join, and Element Web offers that only for `knock`, not `knock_restricted`
join_rules = state.get(("m.room.join_rules", ""))
if join_rules is None or join_rules.content.get("join_rule") != "knock":
return 403, {
"msg": "Guests can only be invited to rooms they can ask to join",
"reason": "room_not_knockable",
}

if room_id in self._config.rooms_forbidden_to_guests:
return 403, {"msg": "Guests are forbidden from this room"}

# Last, so that a refused request doesn't spend the limit
await self._ratelimiter.ratelimit(requester, n_actions=len(unique_emails))
Comment thread
erikjohnston marked this conversation as resolved.
Resolved

invites: List[Dict[str, str]] = []
for email in unique_emails.values():
localpart = self._config.user_id_prefix + "".join(
secrets.choice(string.ascii_lowercase + string.digits)
for _ in range(32)
)
invites.append({"email": email, "username": localpart})

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This duplicates a small little bit of the guest_registration_servlet to generate a random localpart.
It drops the retry on localpart collision. 32 random characters should be more than random enough to never feasibly collide.
It also drops the existence check. While it could be added here as another layer, the new guest email invites architecture means MAS will check for existence when creating the invite, then MAS will check again when the invited guest follows the invite link to actually create the user account.


logger.info(
"'%s' is inviting %d guest(s) to '%s'", user_id, len(invites), room_id
)
room_name = _non_empty_string(state.get(("m.room.name", "")), "name")
inviter_name = _non_empty_string(
state.get(("m.room.member", user_id)), "displayname"
)
try:
# Outside the inner `try`, because a refused token request is between
# this module and MAS, not the inviter's to see
token = await self._mas_admin_client.request_admin_token()
try:
scheduled = await self._mas_admin_client.invite_guests(
room_id, room_name, user_id, inviter_name, invites, token
)
except HttpResponseException as e:
if e.code == 400:
return 400, {
"msg": _first_error_title(e.response)
or "MAS refused the invites"
}
if e.code == 404:
logger.warning(
"MAS answered guest invites with 404: it's too old, has them "
"turned off, or isn't at 'mas.admin_api_base_url'"
)
return 404, {"msg": "Inviting guests by email is turned off"}
raise
# A timeout is a `SynapseError`, and a connection failure a Twisted error
except Exception:
logger.exception("Failed to send guest invites to MAS")
return 502, {"msg": "Failed to send the invites"}

return 202, {"scheduled": scheduled}

@staticmethod
def _may_invite(user_id: str, state: StateMap[EventBase]) -> bool:
"""Whether this user may invite guests to the room with this state.

The guests are invited by email rather than by a membership event, so
nothing else checks this. It mirrors what Synapse requires of a real
invite: the user is joined to the room and meets its invite power level.
"""
# A room this server isn't in has no state, not even the create event
# that `get_user_power_level` asserts
member = state.get(("m.room.member", user_id))
if member is None or member.content.get("membership") != "join":
return False

return get_user_power_level(user_id, state) >= get_named_level(
state, "invite", 0
)


def _non_empty_string(event: Optional[EventBase], key: str) -> Optional[str]:
"""The event's content value for this key, if it's a non-empty string.

Room members set these, so they can hold anything.
"""
value = event.content.get(key) if event is not None else None
return value if isinstance(value, str) and value else None


def _first_error_title(body: bytes) -> Optional[str]:
"""The first title in a MAS admin API error body, `{"errors": [{"title"}]}`."""
try:
title = json.loads(body)["errors"][0]["title"]
except (ValueError, LookupError, TypeError):
return None
return title if isinstance(title, str) else None
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,8 @@
from synapse.module_api.errors import ConfigError
from synapse.types import UserID

from synapse_guest_module.config import GuestModuleConfig, MasConfig
from synapse_guest_module.config import EmailInvitesConfig, GuestModuleConfig, MasConfig
from synapse_guest_module.guest_invite_servlet import GuestInviteServlet
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
Expand Down Expand Up @@ -58,6 +59,15 @@
self._api.register_web_resource(
"/_synapse/client/register_guest", self.registration_servlet
)

self.invite_servlet: Optional[GuestInviteServlet] = None
if config.email_invites.enabled and mas_admin_client is not None:
self.invite_servlet = GuestInviteServlet(
config, api, mas_admin_client, self._is_module_guest
)
self._api.register_web_resource(
"/_synapse/client/invite_guests", self.invite_servlet
)
self._api.register_third_party_rules_callbacks(
on_profile_update=self.profile_update
)
Expand Down Expand Up @@ -205,6 +215,37 @@
client_secret_filepath,
)

email_invites_config = config.get("email_invites")
if email_invites_config is None:
email_invites_config = {}
if not isinstance(email_invites_config, dict):
raise ConfigError("Config option 'email_invites' must be an object")

email_invites_enabled = email_invites_config.get("enabled", False)
if not isinstance(email_invites_enabled, bool):
raise ConfigError("Config option 'email_invites.enabled' must be a bool")

if email_invites_enabled and mas is None:
raise ConfigError("Config option 'email_invites' requires 'mas'")

max_emails = email_invites_config.get("max_emails", 20)
per_inviter_per_hour = email_invites_config.get("per_inviter_per_hour", 50)
for name, value in (
("max_emails", max_emails),
("per_inviter_per_hour", per_inviter_per_hour),
):
if isinstance(value, bool) or not isinstance(value, int) or value <= 0:
raise ConfigError(
f"Config option 'email_invites.{name}' must be a positive integer"
)

# A request for more emails than the hourly limit could never pass it
if max_emails > per_inviter_per_hour:
raise ConfigError(
"Config option 'email_invites.max_emails' can't exceed "
"'email_invites.per_inviter_per_hour'"
)

return GuestModuleConfig(
user_id_prefix,
display_name_suffix,
Expand All @@ -213,6 +254,9 @@
mas,
hide_room_directory_from_guests,
frozenset(rooms_forbidden_to_guests),
email_invites=EmailInvitesConfig(
email_invites_enabled, max_emails, per_inviter_per_hour
),
)

async def profile_update(
Expand Down Expand Up @@ -309,9 +353,9 @@

async def callback_user_may_join_room(
self, user_id: str, room_id: str, is_invited: bool
) -> Union[
Literal["NOT_SPAM"], errors.Codes, Tuple[errors.Codes, Dict[str, Any]], bool
]:

Check warning on line 358 in modules/restricted-guests/synapse/synapse_guest_module/guest_module.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use a union type expression for this type hint.

See more on https://sonarcloud.io/project/issues?id=element-modules&issues=AaEN8OfmLNgfEFUTKPVX&open=AaEN8OfmLNgfEFUTKPVX&pullRequest=304
"""Returns whether this user is allowed to join a room. Guest users
should only be able to do that if the room is Ask to Join (knock), and never
for a room that is forbidden to guests.
Expand Down
Loading
Loading