Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
63 changes: 63 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,12 @@ 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. Default: `false`.
- `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`.
- `invite_lifetime` - how long an invite link stays valid, in seconds. Default: `604800` (7 days).

Example configuration:

```yaml
Expand All @@ -98,6 +105,12 @@ 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
invite_lifetime: 604800
```

Enable [the Admin API on a MAS
Expand All @@ -121,6 +134,56 @@ 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. The module mints an invite code per address with
`POST /api/admin/v1/user-registration-tokens`, then has MAS email them with
`POST /api/admin/v1/user-registration-tokens/invite-emails`. MAS registers the guests.

```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` or `knock_restricted` (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 a single-use invite code of its own, pinned to that address and
expiring after `invite_lifetime`, which MAS emails a link for, naming the room and the
caller. Every code is minted before any email is sent, so an address MAS refuses sends
no email at all. The links are not in the response:

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

A recipient who follows their link signs up as a guest, 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. MAS assigns the guest's localpart, starting
with `user_id_prefix`, when they sign up, once per address however many invites it gets.

| 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` or `knock_restricted`. For the join rule, the body also has `"reason": "room_not_knockable"`. |
| 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, couldn't be reached, or doesn't serve the endpoints above |

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,17 @@ 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.

# In seconds
invite_lifetime: int = 7 * 24 * 60 * 60


@attr.s(frozen=True, auto_attribs=True)
class GuestModuleConfig:
user_id_prefix: str
Expand All @@ -38,3 +49,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,226 @@
# 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
from datetime import datetime, timezone
from typing import Any, Callable, Dict, Optional, Tuple

from synapse.api.constants import EventContentFields, EventTypes, JoinRules, Membership
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 a guest, which MAS mints an
invite code for and sends out. Nothing exists on this homeserver until a
recipient follows their link, and MAS assigns the guest's localpart then.
"""

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 66 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 19 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]]:
"""Have MAS mint an invite code per address and email out the links.
MAS assigns each guest's localpart from the username template.
"""

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,
[
(EventTypes.Create, ""),
(EventTypes.JoinRules, ""),
(EventTypes.Member, user_id),
(EventTypes.Name, ""),
(EventTypes.PowerLevels, ""),
],
)
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
join_rules = state.get((EventTypes.JoinRules, ""))
if join_rules is None or join_rules.content.get("join_rule") not in (
JoinRules.KNOCK,
JoinRules.KNOCK_RESTRICTED,
):
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

# A minijinja template, which MAS renders when the guest registers
username = self._config.user_id_prefix + "{{ random }}"
expires_at = datetime.fromtimestamp(
self._api._hs.get_clock().time()
+ self._config.email_invites.invite_lifetime,
tz=timezone.utc,
)

logger.info(
"'%s' is inviting %d guest(s) to '%s'", user_id, len(unique_emails), room_id
)
room_name = _non_empty_string(
state.get((EventTypes.Name, "")), EventContentFields.ROOM_NAME
)
inviter_name = _non_empty_string(
state.get((EventTypes.Member, user_id)),
EventContentFields.MEMBERSHIP_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:
# Every address is minted for before any is sent, so a refused
# address sends no email at all
token_ids = [
await self._mas_admin_client.create_registration_token(
email, username, room_id, expires_at, token
)
for email in unique_emails.values()
]
scheduled = await self._mas_admin_client.send_invite_emails(
token_ids, room_name, user_id, inviter_name, 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 or isn't "
"at 'mas.admin_api_base_url'"
)
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((EventTypes.Member, user_id))
if (
member is None
or member.content.get(EventContentFields.MEMBERSHIP) != 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
Loading
Loading