synapse-oidc-upload-rules
A Synapse module that applies per-user media upload limits based on the user's Keycloak group membership.
On every media upload, Synapse asks this module which limits apply to the uploading user. The module resolves the Matrix user to their Keycloak identity (via the user_external_ids mapping created by the OIDC login flow), fetches the user's groups live from the Keycloak Admin REST API (cached with a TTL), and returns the limits of the first matching ruleset.
On Synapse ≥ 1.139.0 that means rolling time-window quotas via get_media_upload_limits_for_user. On Synapse 1.132.0–1.138.x the same rulesets are enforced as a per-upload size cap via is_user_allowed_to_upload_media_of_size (and advertised as m.upload.size).
sequenceDiagram
participant Client
participant Synapse
participant Module
participant Keycloak
Client->>Synapse: upload media
Synapse->>Module: media repository callback
Module->>Synapse: 1. mxid → OIDC `sub` (user_external_ids)
Module->>Keycloak: 2. GET /admin/realms/{realm}/users/{sub}/groups\n(client-credentials token, TTL-cached)
Keycloak-->>Module: groups for user
Module->>Module: 3. first matching ruleset wins
Module-->>Synapse: full: MediaUploadLimit list / legacy: allow-or-deny by size
Requirements
- Synapse ≥ 1.132.0 (hard floor — see Synapse version support below).
- Python ≥ 3.9 (same floor as recent Synapse releases; the package itself only needs
attrs, which Synapse already ships). - Users must log in via Synapse's OIDC provider pointing at Keycloak, so that
user_external_idscontains the mapping from Matrix IDs to Keycloak user IDs.
Synapse version support
Per-user media upload hooks only exist in Synapse from 1.132.0 onwards. There is no module API on older homeservers that can deny or reshape uploads by user, so this package cannot usefully run below that version (startup fails with a clear ConfigError).
| Synapse version | Mode | What is enforced |
|---|---|---|
| ≥ 1.139.0 | Full | Rolling time-window quotas via get_media_upload_limits_for_user (matches Synapse's media_upload_limits semantics). |
| 1.132.0 – 1.138.x | Legacy | Per-upload size cap via is_user_allowed_to_upload_media_of_size, also advertised to clients as m.upload.size through get_media_config_for_user. Rolling quotas are not available. |
| < 1.132.0 | Unsupported | No media repository callbacks; the module refuses to start. |
The mode is detected at startup from the signature of register_media_repository_callbacks. On legacy Synapse the module logs a warning and falls back automatically — no config change needed when you later upgrade past 1.139.0.
All of these callbacks are still marked experimental upstream and may change in future Synapse releases.
Installation
Install the package into the same Python environment as Synapse:
pip install synapse-oidc-upload-rules
# or from a checkout / local build:
uv build && pip install dist/synapse_oidc_upload_rules-*.whl
PyPI: synapse-oidc-upload-rules
Keycloak setup
The module authenticates to the Keycloak Admin REST API with the client-credentials grant, so it needs a confidential client with a service account:
- In your realm, create a client (e.g.
synapse-upload-rules) with:- Client authentication: on (confidential client)
- Service accounts roles: enabled
- All login flows (standard, direct access, …): off
- On the client's Service accounts roles tab, assign the following roles from the
realm-managementclient:view-usersquery-groups
- Copy the client secret from the Credentials tab into the module config (or a file referenced by
client_secret_path).
Configuration (homeserver.yaml)
modules:
- module: synapse_oidc_upload_rules.OidcUploadRulesModule
config:
auth_provider_id: "oidc-keycloak" # idp_id as stored in user_external_ids
keycloak:
server_url: "https://keycloak.example.com"
realm: "my-realm"
client_id: "synapse-upload-rules"
client_secret: "..." # or client_secret_path
timeout_s: 5
cache_ttl: "5m" # group-membership cache TTL
on_error: "fallback" # "fallback" or "deny" (see below)
rulesets: # first match wins, evaluated in order
- name: "power-users"
groups: ["/staff", "/premium"] # Keycloak group paths
require_all: false # match ANY listed group (default)
limits:
- time_period: "1d"
max_size: "10G"
- name: "restricted"
groups: ["/guests"]
limits:
- time_period: "1w"
max_size: "100M"
default_limits: # used when no ruleset matches;
- time_period: "1d" # omit to fall back to the homeserver's
max_size: "500M" # own media_upload_limits
Reference
| Key | Required | Default | Description |
|---|---|---|---|
auth_provider_id |
yes | — | The idp_id under which OIDC mappings are stored in user_external_ids (Synapse prefixes OIDC providers with oidc-). |
keycloak.server_url |
yes | — | Base URL of Keycloak (no /auth suffix on modern Keycloak). |
keycloak.realm |
yes | — | Realm containing the users and the service-account client. |
keycloak.client_id |
yes | — | Client ID of the confidential service-account client. |
keycloak.client_secret |
yes* | — | Client secret. Mutually exclusive with client_secret_path. |
keycloak.client_secret_path |
yes* | — | Path to a file containing the client secret (trailing whitespace is stripped). |
keycloak.timeout_s |
no | 5 |
Intended HTTP timeout for Keycloak requests. |
cache_ttl |
no | "5m" |
How long group memberships are cached per user. |
on_error |
no | "fallback" |
What to do when Keycloak/DB lookups fail: fallback returns None (homeserver config applies), deny returns the strictest configured limits. |
rulesets |
no** | [] |
Ordered list of rulesets; the first whose groups match wins. |
rulesets[].groups |
yes | — | Keycloak group paths (e.g. /staff), compared exactly (no prefix matching). |
rulesets[].require_all |
no | false |
If true, the user must be in all listed groups; otherwise any suffices. |
rulesets[].limits |
yes | — | List of {time_period, max_size} entries, same semantics as Synapse's media_upload_limits. |
default_limits |
no** | unset | Limits applied when no ruleset matches (or the user has no OIDC mapping). If unset, None is returned and the homeserver config applies. |
* exactly one of client_secret / client_secret_path is required.
** at least one of rulesets / default_limits must be configured.
Durations accept an integer (milliseconds) or a string with an s/m/h/d/w/y suffix. Sizes accept an integer (bytes) or a string with a K/M/G/T suffix (powers of 1024) — the same semantics as Synapse's own config parser.
Behavior notes
- Users without an OIDC mapping for the configured provider (e.g. local admin accounts) get
default_limitsif configured, otherwise the homeserver config applies. The same applies to users whose mapping exists but who have been deleted from Keycloak. - First match wins: rulesets are evaluated strictly in configuration order; put the most privileged rules first.
- Caching / staleness: group memberships are cached for
cache_ttlper user, so group changes in Keycloak can take up to that long to affect upload limits. Access tokens are cached until shortly before expiry and refreshed automatically (including a one-shot retry when Keycloak rejects a token). - Error policy: Keycloak or database failures never crash the upload path; they are logged with context. With
on_error: fallbackthe homeserver's ownmedia_upload_limitsapply; withon_error: denythe strictest configured limits apply (the ruleset — ordefault_limits— with the lowest allowed bytes-per-time rate). - Legacy mode semantics: on Synapse 1.132–1.138, each ruleset's smallest
max_sizeis treated as a per-file upload cap (rollingtime_periodwindows cannot be enforced until 1.139+). Prefer upgrading to ≥ 1.139.0 if you rely on time-window quotas. - Experimental callbacks: the underlying Synapse media repository callbacks are experimental; this module is tested against the 1.132 / 1.139 callback shapes.
Development
The project uses uv with a src/ layout. matrix-synapse is a dev/test dependency only (the module imports synapse.module_api at runtime inside the homeserver); tests mock the ModuleApi and the Keycloak HTTP layer, so no running Synapse or Keycloak is needed.
uv sync # create the venv and install all dependency groups
uv run pytest # run the test suite
uv run ruff check . # lint
uv run ruff format . # format
Releasing
Releases are published to PyPI by .github/workflows/release.yml on a pushed semver tag (or via Actions → Release → Run workflow against an existing tag).
Cut a release:
-
Bump the version in both
pyproject.tomlandsrc/synapse_oidc_upload_rules/__init__.py(they must match). -
Commit the bump, then tag and push:
git tag -a v0.1.0 -m "v0.1.0" git push origin v0.1.0
-
The Release workflow will lint, test, build, publish to PyPI via OIDC Trusted Publishing, and create a GitHub Release with the wheel and sdist attached.
Supported tag shapes: vX.Y.Z, vX.Y.ZaN, vX.Y.ZbN, vX.Y.ZrcN.
License
Copyright (c) 2026 Lukas 'dotWee' Wolfsteiner lukas@wolfsteiner.media
Licensed under the MIT License. See the LICENSE file for details.
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file synapse_oidc_upload_rules-0.1.0.tar.gz.
File metadata
- Download URL: synapse_oidc_upload_rules-0.1.0.tar.gz
- Upload date:
- Size: 15.4 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: uv/0.11.31 {"installer":{"name":"uv","version":"0.11.31","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
b72e7b2bd7bb32a0670710ec2e3aadb16ee04e6783b3bf51feb1ce33f7ce4713
|
|
| MD5 |
cf9a44aabf1e62c8c252c99bc24c1de5
|
|
| BLAKE2b-256 |
1b41f4f007e2bdcd113017017cf819e8a23e7c845eb6e3b97c9ee32b1dbd5346
|
File details
Details for the file synapse_oidc_upload_rules-0.1.0-py3-none-any.whl.
File metadata
- Download URL: synapse_oidc_upload_rules-0.1.0-py3-none-any.whl
- Upload date:
- Size: 18.3 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: uv/0.11.31 {"installer":{"name":"uv","version":"0.11.31","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
45d7cafcf59b4ee1c02a6e117e4725c9ee810ce76dd88218764abcf598e898d5
|
|
| MD5 |
d0e3bc52cf27fc42f5b04eece74a1b89
|
|
| BLAKE2b-256 |
937ecd2b413c857f3f437501c2fafb039ca59d974e3f4da261ff157e2c967111
|