Metadata-Version: 2.4
Name: synapse-oidc-upload-rules
Version: 0.1.0
Summary: Synapse module providing per-user media upload limits based on Keycloak group membership (Synapse >= 1.132.0; full rolling quotas on >= 1.139.0)
Keywords: synapse,synapse-module,synapse-module-api,matrix,matrix-synapse,matrix-synapse-module,oidc,keycloak,media,upload,homeserver
Author: Lukas Wolfsteiner
Author-email: Lukas Wolfsteiner <lukas@wolfsteiner.media>
License-Expression: MIT
License-File: LICENSE
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: System Administrators
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Communications :: Chat
Classifier: Topic :: Internet :: WWW/HTTP
Classifier: Typing :: Typed
Requires-Dist: attrs>=23.0.0
Requires-Python: >=3.9
Project-URL: Homepage, https://github.com/dotWee/synapse-oidc-upload-rules
Project-URL: Repository, https://github.com/dotWee/synapse-oidc-upload-rules
Project-URL: Issues, https://github.com/dotWee/synapse-oidc-upload-rules/issues
Project-URL: Changelog, https://github.com/dotWee/synapse-oidc-upload-rules/releases
Description-Content-Type: text/markdown

# synapse-oidc-upload-rules

A [Synapse](https://github.com/element-hq/synapse) [module](https://element-hq.github.io/synapse/latest/modules/index.html) 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`).

```mermaid
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](#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_ids` contains 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. |
| **&lt; 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:

```sh
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](https://pypi.org/project/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:

1. 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
2. On the client's **Service accounts roles** tab, assign the following roles from the `realm-management` client:
   - `view-users`
   - `query-groups`
3. Copy the client secret from the **Credentials** tab into the module config (or a file referenced by `client_secret_path`).

## Configuration (homeserver.yaml)

```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_limits` if 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_ttl` per 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: fallback` the homeserver's own `media_upload_limits` apply; with `on_error: deny` the strictest configured limits apply (the ruleset — or `default_limits` — with the lowest allowed bytes-per-time rate).
- **Legacy mode semantics**: on Synapse 1.132–1.138, each ruleset's smallest `max_size` is treated as a per-file upload cap (rolling `time_period` windows 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](https://github.com/astral-sh/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.

```sh
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`](.github/workflows/release.yml) on a pushed semver tag (or via **Actions → Release → Run workflow** against an existing tag).

Cut a release:

1. Bump the version in both `pyproject.toml` and `src/synapse_oidc_upload_rules/__init__.py` (they must match).
2. Commit the bump, then tag and push:

   ```sh
   git tag -a v0.1.0 -m "v0.1.0"
   git push origin v0.1.0
   ```

3. 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](LICENSE) file for details.
