diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
index d91f9c29187..6329fc34036 100644
--- a/.github/workflows/tests.yml
+++ b/.github/workflows/tests.yml
@@ -155,7 +155,7 @@ jobs:
uses: matrix-org/setup-python-poetry@v1
with:
# We want to make use of type hints in optional dependencies too.
- extras: all
+ extras: "all scim"
# We have seen odd mypy failures that were resolved when we started
# installing the project again:
# https://github.com/matrix-org/synapse/pull/15376#issuecomment-1498983775
diff --git a/changelog.d/17144.feature b/changelog.d/17144.feature
new file mode 100644
index 00000000000..9e5b5633230
--- /dev/null
+++ b/changelog.d/17144.feature
@@ -0,0 +1 @@
+Implement a SCIM provisioning API.
diff --git a/docs/SUMMARY.md b/docs/SUMMARY.md
index fd91d9fa115..4c6dfde2445 100644
--- a/docs/SUMMARY.md
+++ b/docs/SUMMARY.md
@@ -71,6 +71,7 @@
- [Users](admin_api/user_admin_api.md)
- [Server Version](admin_api/version_api.md)
- [Federation](usage/administration/admin_api/federation.md)
+ - [SCIM provisioning](usage/administration/admin_api/scim_api.md)
- [Manhole](manhole.md)
- [Monitoring](metrics-howto.md)
- [Reporting Homeserver Usage Statistics](usage/administration/monitoring/reporting_homeserver_usage_statistics.md)
diff --git a/docs/usage/administration/admin_api/scim_api.md b/docs/usage/administration/admin_api/scim_api.md
new file mode 100644
index 00000000000..093cedcefe9
--- /dev/null
+++ b/docs/usage/administration/admin_api/scim_api.md
@@ -0,0 +1,284 @@
+# SCIM API
+
+Synapse implements a basic subset of the SCIM 2.0 provisioning protocol as defined in [RFC7643](https://datatracker.ietf.org/doc/html/rfc7643) and [RFC7644](https://datatracker.ietf.org/doc/html/rfc7643).
+This allows Identity Provider software to update user attributes in a standard and centralized way.
+
+The SCIM endpoint is `/_synapse/admin/scim/v2`.
+
+
+
+The synapse SCIM API is an experimental feature, and it is disabled by default.
+It might be removed someday in favor of an implementation in the [Matrix Authentication Service](https://github.com/element-hq/matrix-authentication-service).
+
+
+
+## Installation
+
+SCIM support for Synapse requires python 3.9+. The `matrix-synapse` package should be installed with the `scim` extra. e.g. with `pip install matrix-synapse[scim]`. For compatibility reasons, the SCIM support cannot be included in the `all` extra, so you need to explicitly use the `scim` extra to enable the API.
+
+Then it must be explicitly enabled by configuration:
+
+```yaml
+experimental_features:
+ msc4098:
+ enabled: true
+ idp_id:
+```
+
+## Examples
+
+This sections presents examples of SCIM requests and responses that are supported by the synapse implementation.
+Tools like [scim2-cli](https://scim2-cli.readthedocs.io) can be used to manually build payloads and send requests to the SCIM endpoint.
+
+### Create user
+
+#### Request
+
+```
+POST /_synapse/admin/scim/v2/Users
+```
+
+```json
+{
+ "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"],
+ "userName": "bjensen",
+ "externalId": "bjensen@test",
+ "phoneNumbers": [{"value": "+1-12345678"}],
+ "emails": [{"value": "bjensen@mydomain.tld"}],
+ "photos": [
+ {
+ "type": "photo",
+ "primary": true,
+ "value": "https://mydomain.tld/photo.webp"
+ }
+ ],
+ "displayName": "bjensen display name",
+ "password": "correct horse battery staple"
+}
+```
+
+#### Response
+
+```json
+{
+ "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"],
+ "meta": {
+ "resourceType": "User",
+ "created": "2024-07-22T16:59:16.326188+00:00",
+ "lastModified": "2024-07-22T16:59:16.326188+00:00",
+ "location": "https://synapse.example/_synapse/admin/scim/v2/Users/@bjensen:test",
+ },
+ "id": "@bjensen:test",
+ "externalId": "@bjensen:test",
+ "phoneNumbers": [{"value": "+1-12345678"}],
+ "userName": "bjensen",
+ "emails": [{"value": "bjensen@mydomain.tld"}],
+ "active": true,
+ "photos": [
+ {
+ "type": "photo",
+ "primary": true,
+ "value": "https://mydomain.tld/photo.webp"
+ }
+ ],
+ "displayName": "bjensen display name"
+}
+```
+
+### Get user
+
+#### Request
+
+```
+GET /_synapse/admin/scim/v2/Users/@bjensen:test
+```
+
+#### Response
+
+```json
+{
+ "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"],
+ "meta": {
+ "resourceType": "User",
+ "created": "2024-07-22T16:59:16.326188+00:00",
+ "lastModified": "2024-07-22T16:59:16.326188+00:00",
+ "location": "https://synapse.example/_synapse/admin/scim/v2/Users/@bjensen:test",
+ },
+ "id": "@bjensen:test",
+ "externalId": "@bjensen:test",
+ "phoneNumbers": [{"value": "+1-12345678"}],
+ "userName": "bjensen",
+ "emails": [{"value": "bjensen@mydomain.tld"}],
+ "active": true,
+ "photos": [
+ {
+ "type": "photo",
+ "primary": true,
+ "value": "https://mydomain.tld/photo.webp"
+ }
+ ],
+ "displayName": "bjensen display name"
+}
+```
+
+### Get users
+
+#### Request
+
+Note that requests can be paginated using the `startIndex` and the `count` query string parameters:
+
+```
+GET /_synapse/admin/scim/v2/Users?startIndex=10&count=1
+```
+
+
+
+For performances reason, the page count will be maxed to 1000.
+
+
+
+#### Response
+
+```json
+{
+ "schemas": ["urn:ietf:params:scim:api:messages:2.0:ListResponse"],
+ "totalResults": 123,
+ "Resources": [
+ {
+ "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"],
+ "meta": {
+ "resourceType": "User",
+ "created": "2024-07-22T16:59:16.326188+00:00",
+ "lastModified": "2024-07-22T16:59:16.326188+00:00",
+ "location": "https://synapse.example/_synapse/admin/scim/v2/Users/@bjensen:test",
+ },
+ "id": "@bjensen:test",
+ "externalId": "@bjensen:test",
+ "phoneNumbers": [{"value": "+1-12345678"}],
+ "userName": "bjensen",
+ "emails": [{"value": "bjensen@mydomain.tld"}],
+ "active": true,
+ "photos": [
+ {
+ "type": "photo",
+ "primary": true,
+ "value": "https://mydomain.tld/photo.webp"
+ }
+ ],
+ "displayName": "bjensen display name"
+ }
+ ]
+}
+```
+
+### Replace user
+
+#### Request
+
+```
+PUT /_synapse/admin/scim/v2/Users/@bjensen:test
+```
+
+```json
+{
+ "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"],
+ "userName": "bjensen",
+ "externalId": "bjensen@test",
+ "phoneNumbers": [{"value": "+1-12345678"}],
+ "emails": [{"value": "bjensen@mydomain.tld"}],
+ "active": true,
+ "photos": [
+ {
+ "type": "photo",
+ "primary": true,
+ "value": "https://mydomain.tld/photo.webp"
+ }
+ ],
+ "displayName": "bjensen new display name",
+ "password": "correct horse battery staple"
+}
+```
+
+#### Response
+
+```json
+{
+ "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"],
+ "meta": {
+ "resourceType": "User",
+ "created": "2024-07-22T16:59:16.326188+00:00",
+ "lastModified": "2024-07-22T17:34:12.834684+00:00",
+ "location": "https://synapse.example/_synapse/admin/scim/v2/Users/@bjensen:test",
+ },
+ "id": "@bjensen:test",
+ "externalId": "@bjensen:test",
+ "phoneNumbers": [{"value": "+1-12345678"}],
+ "userName": "bjensen",
+ "emails": [{"value": "bjensen@mydomain.tld"}],
+ "active": true,
+ "photos": [
+ {
+ "type": "photo",
+ "primary": true,
+ "value": "https://mydomain.tld/photo.webp"
+ }
+ ],
+ "displayName": "bjensen new display name"
+}
+```
+
+### Delete user
+
+User deletion requests [deactivate](../../../admin_api/user_admin_api.md) users, with the `erase` option.
+
+#### Request
+
+```
+DELETE /_synapse/admin/scim/v2/Users/@bjensen:test
+```
+
+## Implementation details
+
+### Models
+
+The only SCIM resource type implemented is `User`, with the following attributes:
+- `userName`
+- `password`
+- `emails`
+- `phoneNumbers`
+- `displayName`
+- `photos` (as a MXC URI)
+- `active`
+
+The other SCIM User attributes will be ignored. Other resource types such as `Group` are not implemented.
+
+### Endpoints
+
+The implemented endpoints are:
+
+- `/Users` (GET, POST)
+- `/Users/` (GET, PUT, DELETE)
+- `/ServiceProviderConfig` (GET)
+- `/Schemas` (GET)
+- `/Schemas/` (GET)
+- `/ResourceTypes` (GET)
+- `/ResourceTypes/`
+
+The following endpoints are not implemented:
+
+- `/Users` (PATCH)
+- [`/Me`](https://datatracker.ietf.org/doc/html/rfc7644#section-3.11) (GET, POST, PUT, PATCH, DELETE)
+- `/Groups` (GET, POST, PUT, PATCH)
+- [`/Bulk`](https://datatracker.ietf.org/doc/html/rfc7644#section-3.7) (POST)
+- [`/.search`](https://datatracker.ietf.org/doc/html/rfc7644#section-3.4.3) (POST)
+
+### Features
+
+The following features are implemented:
+- [pagination](https://datatracker.ietf.org/doc/html/rfc7644#section-3.4.2.4)
+
+The following features are not implemented:
+- [filtering](https://datatracker.ietf.org/doc/html/rfc7644#section-3.4.2.2)
+- [sorting](https://datatracker.ietf.org/doc/html/rfc7644#section-3.4.2.3)
+- [attributes selection](https://datatracker.ietf.org/doc/html/rfc7644#section-3.4.2.5)
+- [ETags](https://datatracker.ietf.org/doc/html/rfc7644#section-3.14)
diff --git a/poetry.lock b/poetry.lock
index eece2210956..dda99b1b7a8 100644
--- a/poetry.lock
+++ b/poetry.lock
@@ -1,4 +1,4 @@
-# This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand.
+# This file is automatically @generated by Poetry 1.8.4 and should not be changed by hand.
[[package]]
name = "annotated-types"
@@ -431,6 +431,26 @@ wrapt = ">=1.10,<2"
[package.extras]
dev = ["PyTest", "PyTest (<5)", "PyTest-Cov", "PyTest-Cov (<2.6)", "bump2version (<1)", "configparser (<5)", "importlib-metadata (<3)", "importlib-resources (<4)", "sphinx (<2)", "sphinxcontrib-websupport (<2)", "tox", "zipp (<2)"]
+[[package]]
+name = "dnspython"
+version = "2.7.0"
+description = "DNS toolkit"
+optional = true
+python-versions = ">=3.9"
+files = [
+ {file = "dnspython-2.7.0-py3-none-any.whl", hash = "sha256:b4c34b7d10b51bcc3a5071e7b8dee77939f1e878477eeecc965e9835f63c6c86"},
+ {file = "dnspython-2.7.0.tar.gz", hash = "sha256:ce9c432eda0dc91cf618a5cedf1a4e142651196bbcd2c80e89ed5a907e5cfaf1"},
+]
+
+[package.extras]
+dev = ["black (>=23.1.0)", "coverage (>=7.0)", "flake8 (>=7)", "hypercorn (>=0.16.0)", "mypy (>=1.8)", "pylint (>=3)", "pytest (>=7.4)", "pytest-cov (>=4.1.0)", "quart-trio (>=0.11.0)", "sphinx (>=7.2.0)", "sphinx-rtd-theme (>=2.0.0)", "twine (>=4.0.0)", "wheel (>=0.42.0)"]
+dnssec = ["cryptography (>=43)"]
+doh = ["h2 (>=4.1.0)", "httpcore (>=1.0.0)", "httpx (>=0.26.0)"]
+doq = ["aioquic (>=1.0.0)"]
+idna = ["idna (>=3.7)"]
+trio = ["trio (>=0.23)"]
+wmi = ["wmi (>=1.5.1)"]
+
[[package]]
name = "docutils"
version = "0.19"
@@ -456,6 +476,21 @@ files = [
[package.extras]
dev = ["Sphinx", "coverage", "flake8", "lxml", "lxml-stubs", "memory-profiler", "memray", "mypy", "tox", "xmlschema (>=2.0.0)"]
+[[package]]
+name = "email-validator"
+version = "2.2.0"
+description = "A robust email address syntax and deliverability validation library."
+optional = true
+python-versions = ">=3.8"
+files = [
+ {file = "email_validator-2.2.0-py3-none-any.whl", hash = "sha256:561977c2d73ce3611850a06fa56b414621e0c8faa9d66f2611407d87465da631"},
+ {file = "email_validator-2.2.0.tar.gz", hash = "sha256:cb690f344c617a714f22e66ae771445a1ceb46821152df8e165c5f9a364582b7"},
+]
+
+[package.dependencies]
+dnspython = ">=2.0.0"
+idna = ">=2.0.0"
+
[[package]]
name = "gitdb"
version = "4.0.10"
@@ -1671,6 +1706,7 @@ files = [
[package.dependencies]
annotated-types = ">=0.6.0"
+email-validator = {version = ">=2.0.0", optional = true, markers = "extra == \"email\""}
pydantic-core = "2.23.4"
typing-extensions = [
{version = ">=4.12.2", markers = "python_version >= \"3.13\""},
@@ -2281,6 +2317,20 @@ files = [
{file = "ruff-0.7.3.tar.gz", hash = "sha256:e1d1ba2e40b6e71a61b063354d04be669ab0d39c352461f3d789cac68b54a313"},
]
+[[package]]
+name = "scim2-models"
+version = "0.2.5"
+description = "SCIM2 models serialization and validation with pydantic"
+optional = true
+python-versions = ">=3.9"
+files = [
+ {file = "scim2_models-0.2.5-py3-none-any.whl", hash = "sha256:c4052399003f7c60be1e3f045704fc203fa25de23cf602a004c6f01d24667752"},
+ {file = "scim2_models-0.2.5.tar.gz", hash = "sha256:945e5caa544fc21953b190f40cb8b3941ec8433c5cb91a6f3ed8b45691760997"},
+]
+
+[package.dependencies]
+pydantic = {version = ">=2.7.0,<4", extras = ["email"]}
+
[[package]]
name = "secretstorage"
version = "3.3.3"
@@ -3092,6 +3142,7 @@ opentracing = ["jaeger-client", "opentracing"]
postgres = ["psycopg2", "psycopg2cffi", "psycopg2cffi-compat"]
redis = ["hiredis", "txredisapi"]
saml2 = ["pysaml2"]
+scim = ["scim2-models"]
sentry = ["sentry-sdk"]
systemd = ["systemd-python"]
test = ["idna", "parameterized"]
@@ -3101,4 +3152,4 @@ user-search = ["pyicu"]
[metadata]
lock-version = "2.0"
python-versions = "^3.9.0"
-content-hash = "d71159b19349fdc0b7cd8e06e8c8778b603fc37b941c6df34ddc31746783d94d"
+content-hash = "83c3df945563fa06872860be008313292b48ec0e61b1572c10e7bbf75953b816"
diff --git a/pyproject.toml b/pyproject.toml
index 36d34371854..41d37e53785 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -236,6 +236,7 @@ psycopg2cffi = { version = ">=2.8", markers = "platform_python_implementation ==
psycopg2cffi-compat = { version = "==1.1", markers = "platform_python_implementation == 'PyPy'", optional = true }
pysaml2 = { version = ">=4.5.0", optional = true }
authlib = { version = ">=0.15.1", optional = true }
+scim2-models = { version = ">=0.2.5", markers="python_version>='3.9'", optional = true }
# systemd-python is necessary for logging to the systemd journal via
# `systemd.journal.JournalHandler`, as is documented in
# `contrib/systemd/log_config.yaml`.
@@ -259,6 +260,7 @@ matrix-synapse-ldap3 = ["matrix-synapse-ldap3"]
postgres = ["psycopg2", "psycopg2cffi", "psycopg2cffi-compat"]
saml2 = ["pysaml2"]
oidc = ["authlib"]
+scim = ["scim2-models"]
# systemd-python is necessary for logging to the systemd journal via
# `systemd.journal.JournalHandler`, as is documented in
# `contrib/systemd/log_config.yaml`.
diff --git a/synapse/config/experimental.py b/synapse/config/experimental.py
index 3411179a2a3..ddc6666e921 100644
--- a/synapse/config/experimental.py
+++ b/synapse/config/experimental.py
@@ -256,6 +256,19 @@ class MSC3866Config:
require_approval_for_new_accounts: bool = False
+@attr.s(auto_attribs=True, frozen=True, slots=True)
+class MSC4098Config:
+ """Configuration for MSC4098 (SCIM provisioning)"""
+
+ # Whether the SCIM provisioning API is enabled.
+ enabled: bool = False
+
+ # The ID of the IDP that will be associated with the SCIM 'externalId' parameter.
+ # This should be one of the values used in the SSO config.
+ # If unset, a default '__scim__' id will be used.
+ idp_id: Optional[str] = None
+
+
class ExperimentalConfig(Config):
"""Config section for enabling experimental features"""
@@ -415,6 +428,20 @@ def read_config(self, config: JsonDict, **kwargs: Any) -> None:
"msc4069_profile_inhibit_propagation", False
)
+ # MSC4098: SCIM provisioning API
+ try:
+ self.msc4098 = MSC4098Config(**experimental.get("msc4098", {}))
+ if self.msc4098.enabled and self.msc3861.enabled:
+ raise ConfigError(
+ "MSC3861 and MSC4098 are mutually exclusive. Please disable one or the"
+ "other.",
+ ("experimental", "msc4098"),
+ )
+ except ValueError as exc:
+ raise ConfigError(
+ "Invalid MSC4098 configuration", ("experimental", "msc4098")
+ ) from exc
+
# MSC4108: Mechanism to allow OIDC sign in and E2EE set up via QR code
self.msc4108_enabled = experimental.get("msc4108_enabled", False)
diff --git a/synapse/handlers/profile.py b/synapse/handlers/profile.py
index ac4544ca4c0..bdb8d15d5b5 100644
--- a/synapse/handlers/profile.py
+++ b/synapse/handlers/profile.py
@@ -18,6 +18,8 @@
# [This file includes modifications made by New Vector Limited]
#
#
+import hashlib
+import io
import logging
import random
from typing import TYPE_CHECKING, List, Optional, Union
@@ -30,6 +32,8 @@
StoreError,
SynapseError,
)
+from synapse.http.client import SimpleHttpClient
+from synapse.media.media_repository import MediaRepository
from synapse.storage.databases.main.media_repository import LocalMedia, RemoteMedia
from synapse.types import JsonDict, Requester, UserID, create_requester
from synapse.util.caches.descriptors import cached
@@ -399,6 +403,112 @@ async def check_avatar_size_and_mime_type(self, mxc: str) -> bool:
return True
+ async def set_avatar_from_http_url(
+ self,
+ user_id: str,
+ picture_https_url: str,
+ media_repo: Optional[MediaRepository],
+ http_client: SimpleHttpClient,
+ upload_name_prefix: str = "",
+ ) -> bool:
+ """Set avatar of the user.
+
+ This downloads the image file from the URL provided, stores that in
+ the media repository and then sets the avatar on the user's profile.
+
+ It can detect if the same image is being saved again and bails early by storing
+ the hash of the file in the `upload_name` of the avatar image.
+
+ Currently, it only supports server configurations which run the media repository
+ within the same process.
+
+ It silently fails and logs a warning by raising an exception and catching it
+ internally if:
+ * it is unable to fetch the image itself (non 200 status code) or
+ * the image supplied is bigger than max allowed size or
+ * the image type is not one of the allowed image types.
+
+ Args:
+ user_id: matrix user ID in the form @localpart:domain as a string.
+
+ picture_https_url: HTTPS url for the picture image file.
+ media_repo: The media repository in which to store the downloaded picture.
+ http_client: The client used to download the picture.
+ upload_name_prefix: A prefix to the name of the uploaded file.
+
+ Returns: `True` if the user's avatar has been successfully set to the image at
+ `picture_https_url`.
+ """
+ if media_repo is None:
+ logger.info(
+ "failed to set user avatar because out-of-process media repositories "
+ "are not supported yet "
+ )
+ return False
+
+ try:
+ uid = UserID.from_string(user_id)
+
+ def is_allowed_mime_type(content_type: str) -> bool:
+ if (
+ self.allowed_avatar_mimetypes
+ and content_type not in self.allowed_avatar_mimetypes
+ ):
+ return False
+ return True
+
+ # download picture, enforcing size limit & mime type check
+ picture = io.BytesIO()
+
+ content_length, headers, uri, code = await http_client.get_file(
+ url=picture_https_url,
+ output_stream=picture,
+ max_size=self.max_avatar_size,
+ is_allowed_content_type=is_allowed_mime_type,
+ )
+
+ if code != 200:
+ raise Exception(f"GET request to download avatar image returned {code}")
+
+ # upload name includes hash of the image file's content so that we can
+ # easily check if it requires an update or not, the next time user logs in
+ upload_name = (
+ upload_name_prefix + hashlib.sha256(picture.read()).hexdigest()
+ )
+
+ # bail if user already has the same avatar
+ profile = await self.get_profile(user_id)
+ if profile["avatar_url"] is not None:
+ server_name = profile["avatar_url"].split("/")[-2]
+ media_id = profile["avatar_url"].split("/")[-1]
+ if self._is_mine_server_name(server_name):
+ media = await media_repo.store.get_local_media(media_id)
+ if media is not None and upload_name == media.upload_name:
+ logger.info("skipping saving the user avatar")
+ return True
+
+ # store it in media repository
+ avatar_mxc_url = await media_repo.create_content(
+ media_type=headers[b"Content-Type"][0].decode("utf-8"),
+ upload_name=upload_name,
+ content=picture,
+ content_length=content_length,
+ auth_user=uid,
+ )
+
+ # save it as user avatar
+ await self.set_avatar_url(
+ uid,
+ create_requester(uid),
+ str(avatar_mxc_url),
+ )
+
+ logger.info("successfully saved the user avatar")
+ return True
+ except Exception:
+ logger.warning("failed to save the user avatar")
+ return False
+
async def on_profile_query(self, args: JsonDict) -> JsonDict:
"""Handles federation profile query requests."""
diff --git a/synapse/handlers/sso.py b/synapse/handlers/sso.py
index ee74289b6c4..81acad4c965 100644
--- a/synapse/handlers/sso.py
+++ b/synapse/handlers/sso.py
@@ -19,8 +19,6 @@
#
#
import abc
-import hashlib
-import io
import logging
from typing import (
TYPE_CHECKING,
@@ -528,7 +526,13 @@ async def complete_sso_login_request(
user_id_obj, requester, attributes.display_name, True
)
if attributes.picture:
- await self.set_avatar(user_id, attributes.picture)
+ await self._profile_handler.set_avatar_from_http_url(
+ user_id,
+ attributes.picture,
+ self._media_repo,
+ self._http_client,
+ "sso_avatar_",
+ )
await self._auth_handler.complete_sso_login(
user_id,
@@ -743,106 +747,15 @@ async def _register_mapped_user(
# Set avatar, if available
if attributes.picture:
- await self.set_avatar(registered_user_id, attributes.picture)
-
- return registered_user_id
-
- async def set_avatar(self, user_id: str, picture_https_url: str) -> bool:
- """Set avatar of the user.
-
- This downloads the image file from the URL provided, stores that in
- the media repository and then sets the avatar on the user's profile.
-
- It can detect if the same image is being saved again and bails early by storing
- the hash of the file in the `upload_name` of the avatar image.
-
- Currently, it only supports server configurations which run the media repository
- within the same process.
-
- It silently fails and logs a warning by raising an exception and catching it
- internally if:
- * it is unable to fetch the image itself (non 200 status code) or
- * the image supplied is bigger than max allowed size or
- * the image type is not one of the allowed image types.
-
- Args:
- user_id: matrix user ID in the form @localpart:domain as a string.
-
- picture_https_url: HTTPS url for the picture image file.
-
- Returns: `True` if the user's avatar has been successfully set to the image at
- `picture_https_url`.
- """
- if self._media_repo is None:
- logger.info(
- "failed to set user avatar because out-of-process media repositories "
- "are not supported yet "
- )
- return False
-
- try:
- uid = UserID.from_string(user_id)
-
- def is_allowed_mime_type(content_type: str) -> bool:
- if (
- self._profile_handler.allowed_avatar_mimetypes
- and content_type
- not in self._profile_handler.allowed_avatar_mimetypes
- ):
- return False
- return True
-
- # download picture, enforcing size limit & mime type check
- picture = io.BytesIO()
-
- content_length, headers, uri, code = await self._http_client.get_file(
- url=picture_https_url,
- output_stream=picture,
- max_size=self._profile_handler.max_avatar_size,
- is_allowed_content_type=is_allowed_mime_type,
- )
-
- if code != 200:
- raise Exception(
- f"GET request to download sso avatar image returned {code}"
- )
-
- # upload name includes hash of the image file's content so that we can
- # easily check if it requires an update or not, the next time user logs in
- upload_name = "sso_avatar_" + hashlib.sha256(picture.read()).hexdigest()
-
- # bail if user already has the same avatar
- profile = await self._profile_handler.get_profile(user_id)
- if profile["avatar_url"] is not None:
- server_name = profile["avatar_url"].split("/")[-2]
- media_id = profile["avatar_url"].split("/")[-1]
- if self._is_mine_server_name(server_name):
- media = await self._media_repo.store.get_local_media(media_id) # type: ignore[has-type]
- if media is not None and upload_name == media.upload_name:
- logger.info("skipping saving the user avatar")
- return True
-
- # store it in media repository
- avatar_mxc_url = await self._media_repo.create_content(
- media_type=headers[b"Content-Type"][0].decode("utf-8"),
- upload_name=upload_name,
- content=picture,
- content_length=content_length,
- auth_user=uid,
+ await self._profile_handler.set_avatar_from_http_url(
+ registered_user_id,
+ attributes.picture,
+ self._media_repo,
+ self._http_client,
+ "sso_avatar_",
)
- # save it as user avatar
- await self._profile_handler.set_avatar_url(
- uid,
- create_requester(uid),
- str(avatar_mxc_url),
- )
-
- logger.info("successfully saved the user avatar")
- return True
- except Exception:
- logger.warning("failed to save the user avatar")
- return False
+ return registered_user_id
async def complete_sso_ui_auth_request(
self,
diff --git a/synapse/http/server.py b/synapse/http/server.py
index 792961a1476..028df9c46e7 100644
--- a/synapse/http/server.py
+++ b/synapse/http/server.py
@@ -789,7 +789,12 @@ def respond_with_json(
else:
encoder = _encode_json_bytes
- request.setHeader(b"Content-Type", b"application/json")
+ content_types = dict(request.responseHeaders.getAllRawHeaders()).get(
+ b"Content-Type"
+ )
+ content_type = content_types[0] if content_types else b"application/json"
+ request.setHeader(b"Content-Type", content_type)
+
request.setHeader(b"Cache-Control", b"no-cache, no-store, must-revalidate")
if send_cors:
diff --git a/synapse/rest/__init__.py b/synapse/rest/__init__.py
index 4e594e6595f..b50ce0728da 100644
--- a/synapse/rest/__init__.py
+++ b/synapse/rest/__init__.py
@@ -22,7 +22,7 @@
from typing import TYPE_CHECKING, Callable, Dict, Iterable, List, Optional, Tuple
from synapse.http.server import HttpServer, JsonResource
-from synapse.rest import admin
+from synapse.rest import admin, scim
from synapse.rest.client import (
account,
account_data,
@@ -124,6 +124,10 @@
auth_issuer.register_servlets,
)
+if scim.HAS_SCIM2:
+ CLIENT_SERVLET_FUNCTIONS += (scim.register_scim_servlets,)
+
+
SERVLET_GROUPS: Dict[str, Iterable[RegisterServletsFunc]] = {
"client": CLIENT_SERVLET_FUNCTIONS,
}
diff --git a/synapse/rest/admin/__init__.py b/synapse/rest/admin/__init__.py
index 4db89756747..34af0ef497c 100644
--- a/synapse/rest/admin/__init__.py
+++ b/synapse/rest/admin/__init__.py
@@ -116,6 +116,7 @@
UserTokenRestServlet,
WhoisRestServlet,
)
+from synapse.rest.scim import HAS_SCIM2, register_scim_servlets
from synapse.types import JsonDict, RoomStreamToken, TaskStatus
from synapse.util import SYNAPSE_VERSION
@@ -335,6 +336,9 @@ def register_servlets(hs: "HomeServer", http_server: HttpServer) -> None:
if hs.config.experimental.msc3823_account_suspension:
SuspendAccountRestServlet(hs).register(http_server)
+ if HAS_SCIM2:
+ register_scim_servlets(hs, http_server)
+
def register_servlets_for_client_rest_resource(
hs: "HomeServer", http_server: HttpServer
diff --git a/synapse/rest/scim.py b/synapse/rest/scim.py
new file mode 100644
index 00000000000..4e3025a5eed
--- /dev/null
+++ b/synapse/rest/scim.py
@@ -0,0 +1,696 @@
+"""This module implements a subset of the SCIM user provisioning protocol,
+as proposed in the MSC4098.
+
+The implemented endpoints are:
+- /User (GET, POST, PUT, DELETE)
+- /ServiceProviderConfig (GET)
+- /Schemas (GET)
+- /ResourceTypes (GET)
+
+The supported SCIM User attributes are:
+- userName
+- password
+- emails
+- phoneNumbers
+- displayName
+- photos
+- active
+
+References:
+https://github.com/matrix-org/matrix-spec-proposals/pull/4098
+https://datatracker.ietf.org/doc/html/rfc7642
+https://datatracker.ietf.org/doc/html/rfc7643
+https://datatracker.ietf.org/doc/html/rfc7644
+"""
+
+import datetime
+import logging
+import re
+from http import HTTPStatus
+from typing import TYPE_CHECKING, Dict, List, Optional, Tuple, TypeVar, Union
+
+from synapse.api.errors import SynapseError
+from synapse.http.server import HttpServer
+from synapse.http.servlet import (
+ RestServlet,
+ parse_integer,
+ parse_json_object_from_request,
+ parse_strings_from_args,
+)
+from synapse.http.site import SynapseRequest
+from synapse.rest.admin._base import assert_requester_is_admin, assert_user_is_admin
+from synapse.rest.client.register import RegisterRestServlet
+from synapse.types import JsonDict, UserID
+from synapse.util.templates import mxc_to_http
+
+try:
+ from scim2_models import (
+ AuthenticationScheme,
+ Bulk,
+ ChangePassword,
+ Context,
+ Email,
+ Error,
+ ETag,
+ Filter,
+ ListResponse,
+ Meta,
+ Patch,
+ PhoneNumber,
+ Photo,
+ ResourceType,
+ Schema,
+ SearchRequest,
+ ServiceProviderConfig,
+ Sort,
+ User,
+ )
+
+ HAS_SCIM2 = True
+
+except ImportError:
+ HAS_SCIM2 = False
+
+if TYPE_CHECKING:
+ from synapse.server import HomeServer
+
+SCIM_PREFIX = "/_synapse/admin/scim/v2"
+SCIM_DEFAULT_IDP_ID = "__scim__"
+
+logger = logging.getLogger(__name__)
+
+
+def register_scim_servlets(hs: "HomeServer", http_server: HttpServer) -> None:
+ if not hs.config.experimental.msc4098.enabled:
+ return
+
+ SchemaListServlet(hs).register(http_server)
+ SchemaServlet(hs).register(http_server)
+ ResourceTypeListServlet(hs).register(http_server)
+ ResourceTypeServlet(hs).register(http_server)
+ ServiceProviderConfigServlet(hs).register(http_server)
+
+ UserListServlet(hs).register(http_server)
+ UserServlet(hs).register(http_server)
+
+
+T = TypeVar("T")
+
+
+class SCIMServlet(RestServlet):
+ def __init__(self, hs: "HomeServer"):
+ self.hs = hs
+ self.config = hs.config
+ self.store = hs.get_datastores().main
+ self.auth = hs.get_auth()
+ self.auth_handler = hs.get_auth_handler()
+ self.is_mine = hs.is_mine
+ self.profile_handler = hs.get_profile_handler()
+
+ self.default_nb_items_per_page = 100
+ self.max_nb_items_per_page = 1000
+
+ def make_response(
+ self,
+ request: SynapseRequest,
+ status: Union[int, HTTPStatus],
+ payload: T,
+ ) -> Tuple[Union[int, HTTPStatus], T]:
+ """Create a SCIM response, and adds the expected headers."""
+ request.setHeader(b"Content-Type", b"application/scim+json")
+ return status, payload
+
+ def make_error_response(
+ self, request: SynapseRequest, status: Union[int, HTTPStatus], message: str
+ ) -> Tuple[Union[int, HTTPStatus], JsonDict]:
+ """Create a SCIM Error object intended to be returned as HTTP response."""
+ return self.make_response(
+ request,
+ status,
+ Error(
+ status=status.value if isinstance(status, HTTPStatus) else status,
+ detail=message,
+ ).model_dump(),
+ )
+
+ def parse_search_request(self, request: SynapseRequest) -> "SearchRequest":
+ """Build a SCIM SearchRequest object from the HTTP request arguments."""
+ args: Dict[bytes, List[bytes]] = request.args # type: ignore
+ count = min(
+ parse_integer(
+ request, "count", default=self.default_nb_items_per_page, negative=False
+ ),
+ self.max_nb_items_per_page,
+ )
+ return SearchRequest(
+ attributes=parse_strings_from_args(args, "attributes"),
+ excluded_attributes=parse_strings_from_args(args, "excludedAttributes"),
+ start_index=parse_integer(request, "startIndex", default=1, negative=False),
+ count=count,
+ )
+
+ async def get_scim_external_id(self, user_id: str) -> Optional[str]:
+ """Read the external id stored in the special SCIM IDP."""
+
+ scim_idp_id = self.hs.config.experimental.msc4098.idp_id or SCIM_DEFAULT_IDP_ID
+ external_ids = await self.store.get_external_ids_by_user(user_id)
+ for idp_id, external_id in external_ids:
+ if idp_id == scim_idp_id:
+ return external_id
+
+ return None
+
+ async def get_scim_user(self, user_id: str) -> "User":
+ """Create a SCIM User object from a synapse user_id.
+
+ The objects are intended be used as HTTP responses."""
+
+ user_id_obj = UserID.from_string(user_id)
+ user = await self.store.get_user_by_id(user_id)
+ profile = await self.store.get_profileinfo(user_id_obj)
+ threepids = await self.store.user_get_threepids(user_id)
+
+ if not user:
+ raise SynapseError(
+ HTTPStatus.NOT_FOUND,
+ "User not found",
+ )
+
+ if not self.is_mine(user_id_obj):
+ raise SynapseError(
+ HTTPStatus.BAD_REQUEST,
+ "Only local users can be admins of this homeserver",
+ )
+
+ creation_datetime = datetime.datetime.fromtimestamp(user.creation_ts)
+ external_id = await self.get_scim_external_id(user_id)
+ scim_user = User(
+ meta=Meta(
+ resource_type="User",
+ created=creation_datetime,
+ last_modified=creation_datetime,
+ location=f"{self.config.server.public_baseurl}{SCIM_PREFIX[1:]}/Users/{user_id}",
+ ),
+ id=user_id,
+ external_id=external_id,
+ user_name=user_id_obj.localpart,
+ display_name=profile.display_name,
+ active=not user.is_deactivated,
+ emails=[
+ Email(value=threepid.address)
+ for threepid in threepids
+ if threepid.medium == "email"
+ ]
+ or None,
+ phone_numbers=[
+ PhoneNumber(value=threepid.address)
+ for threepid in threepids
+ if threepid.medium == "msisdn"
+ ]
+ or None,
+ )
+
+ if profile.avatar_url:
+ http_url = mxc_to_http(
+ self.hs.config.server.public_baseurl, profile.avatar_url
+ )
+ scim_user.photos = [
+ Photo(
+ type=Photo.Type.photo,
+ primary=True,
+ value=http_url,
+ )
+ ]
+
+ return scim_user
+
+
+class UserServlet(SCIMServlet):
+ """Servlet implementing the SCIM /Users/* endpoints.
+
+ Details are available on RFC7644:
+ https://datatracker.ietf.org/doc/html/rfc7644#section-3.2
+ """
+
+ PATTERNS = [re.compile(f"^{SCIM_PREFIX}/Users/(?P[^/]*)")]
+
+ async def on_GET(
+ self, request: SynapseRequest, user_id: str
+ ) -> Tuple[Union[int, HTTPStatus], JsonDict]:
+ """Implement the RFC7644 'Retrieving a Known Resource' endpoint.
+
+ As defined in:
+ https://datatracker.ietf.org/doc/html/rfc7644#section-3.4.1"""
+
+ await assert_requester_is_admin(self.auth, request)
+ try:
+ user = await self.get_scim_user(user_id)
+ req = self.parse_search_request(request)
+ payload = user.model_dump(
+ scim_ctx=Context.RESOURCE_QUERY_RESPONSE,
+ attributes=req.attributes,
+ excluded_attributes=req.excluded_attributes,
+ )
+ return self.make_response(request, HTTPStatus.OK, payload)
+ except SynapseError as exc:
+ return self.make_error_response(request, exc.code, exc.msg)
+
+ async def on_DELETE(
+ self, request: SynapseRequest, user_id: str
+ ) -> Tuple[Union[int, HTTPStatus], Union[str, JsonDict]]:
+ """Implement the RFC7644 resource deletion endpoint.
+
+ As defined in:
+ https://datatracker.ietf.org/doc/html/rfc7644#section-3.6"""
+
+ requester = await self.auth.get_user_by_req(request)
+ await assert_user_is_admin(self.auth, requester)
+ deactivate_account_handler = self.hs.get_deactivate_account_handler()
+ try:
+ await deactivate_account_handler.deactivate_account(
+ user_id, erase_data=True, requester=requester, by_admin=True
+ )
+ except SynapseError as exc:
+ return self.make_error_response(request, exc.code, exc.msg)
+
+ return self.make_response(request, HTTPStatus.NO_CONTENT, "")
+
+ async def on_PUT(
+ self, request: SynapseRequest, user_id: str
+ ) -> Tuple[int, JsonDict]:
+ """Implement the RFC7644 resource replacement endpoint.
+
+ As defined in:
+ https://datatracker.ietf.org/doc/html/rfc7644#section-3.5.1"""
+
+ requester = await self.auth.get_user_by_req(request)
+ await assert_user_is_admin(self.auth, requester)
+
+ body = parse_json_object_from_request(request)
+ request_user = User.model_validate(
+ body, scim_ctx=Context.RESOURCE_REPLACEMENT_REQUEST
+ )
+
+ try:
+ user_id_obj = UserID.from_string(user_id)
+
+ threepids = await self.store.user_get_threepids(user_id)
+
+ await self.profile_handler.set_displayname(
+ user_id_obj, requester, request_user.display_name or "", True
+ )
+
+ external_id = await self.get_scim_external_id(user_id)
+ if request_user.external_id != external_id:
+ scim_idp_id = (
+ self.hs.config.experimental.msc4098.idp_id or SCIM_DEFAULT_IDP_ID
+ )
+ if external_id:
+ await self.store.remove_user_external_id(
+ scim_idp_id, external_id, user_id
+ )
+ if request_user.external_id:
+ await self.store.record_user_external_id(
+ scim_idp_id, request_user.external_id, user_id
+ )
+
+ if request_user.photos and request_user.photos[0].value:
+ media_repo = (
+ self.hs.get_media_repository()
+ if self.hs.config.media.can_load_media_repo
+ else None
+ )
+ http_client = self.hs.get_proxied_blocklisted_http_client()
+
+ await self.profile_handler.set_avatar_from_http_url(
+ user_id,
+ str(request_user.photos[0].value),
+ media_repo,
+ http_client,
+ "scim_",
+ )
+
+ if threepids is not None:
+ new_email_threepids = {
+ ("email", email.value)
+ for email in request_user.emails or []
+ if email.value
+ }
+ new_phone_number_threepids = {
+ ("msisdn", phone_number.value)
+ for phone_number in request_user.phone_numbers or []
+ if phone_number.value
+ }
+ new_threepids = new_email_threepids | new_phone_number_threepids
+ # get changed threepids (added and removed)
+ cur_threepids = {
+ (threepid.medium, threepid.address)
+ for threepid in await self.store.user_get_threepids(user_id)
+ }
+ add_threepids = new_threepids - cur_threepids
+ del_threepids = cur_threepids - new_threepids
+
+ # remove old threepids
+ for medium, address in del_threepids:
+ try:
+ # Attempt to remove any known bindings of this third-party ID
+ # and user ID from identity servers.
+ await self.hs.get_identity_handler().try_unbind_threepid(
+ user_id, medium, address, id_server=None
+ )
+ except Exception:
+ logger.exception("Failed to remove threepids")
+ raise SynapseError(500, "Failed to remove threepids")
+
+ # Delete the local association of this user ID and third-party ID.
+ await self.auth_handler.delete_local_threepid(
+ user_id, medium, address
+ )
+
+ # add new threepids
+ current_time = self.hs.get_clock().time_msec()
+ for medium, address in add_threepids:
+ await self.auth_handler.add_threepid(
+ user_id, medium, address, current_time
+ )
+
+ response_user = await self.get_scim_user(user_id)
+ payload = response_user.model_dump(
+ scim_ctx=Context.RESOURCE_REPLACEMENT_RESPONSE
+ )
+ return self.make_response(request, HTTPStatus.OK, payload)
+
+ except SynapseError as exc:
+ return self.make_error_response(request, exc.code, exc.msg)
+
+
+class UserListServlet(SCIMServlet):
+ """Servlet implementing the SCIM /Users endpoint.
+
+ Details are available on RFC7644:
+ https://datatracker.ietf.org/doc/html/rfc7644#section-3.2
+ """
+
+ PATTERNS = [re.compile(f"^{SCIM_PREFIX}/Users/?$")]
+
+ async def on_GET(self, request: SynapseRequest) -> Tuple[int, JsonDict]:
+ """Implement the RFC7644 resource query endpoint.
+
+ As defined in:
+ https://datatracker.ietf.org/doc/html/rfc7644#section-3.4.2"""
+
+ try:
+ await assert_requester_is_admin(self.auth, request)
+ req = self.parse_search_request(request)
+
+ items, total = await self.store.get_users_paginate(
+ start=(req.start_index or 0) - 1,
+ limit=req.count or 0,
+ )
+ users = [await self.get_scim_user(item.name) for item in items]
+ list_response = ListResponse[User](
+ start_index=req.start_index,
+ items_per_page=req.count,
+ total_results=total,
+ resources=users,
+ )
+ payload = list_response.model_dump(
+ scim_ctx=Context.RESOURCE_QUERY_RESPONSE,
+ )
+ return self.make_response(request, HTTPStatus.OK, payload)
+
+ except SynapseError as exc:
+ return self.make_error_response(request, exc.code, exc.msg)
+
+ async def on_POST(self, request: SynapseRequest) -> Tuple[int, JsonDict]:
+ """Implement the RFC7644 resource creation endpoint.
+
+ As defined in:
+ https://datatracker.ietf.org/doc/html/rfc7644#section-3.3"""
+
+ try:
+ requester = await self.auth.get_user_by_req(request)
+ await assert_user_is_admin(self.auth, requester)
+
+ body = parse_json_object_from_request(request)
+ request_user = User.model_validate(
+ body, scim_ctx=Context.RESOURCE_CREATION_REQUEST
+ )
+
+ register = RegisterRestServlet(self.hs)
+
+ password_hash = (
+ await self.auth_handler.hash(request_user.password)
+ if request_user.password
+ else None
+ )
+ user_id = await register.registration_handler.register_user(
+ by_admin=True,
+ approved=True,
+ localpart=request_user.user_name,
+ password_hash=password_hash,
+ default_display_name=request_user.display_name,
+ )
+
+ if request_user.external_id:
+ scim_idp_id = (
+ self.hs.config.experimental.msc4098.idp_id or SCIM_DEFAULT_IDP_ID
+ )
+ await self.store.record_user_external_id(
+ scim_idp_id, request_user.external_id, user_id
+ )
+
+ now_ts = self.hs.get_clock().time_msec()
+ if request_user.emails:
+ for email in request_user.emails:
+ if email.value:
+ await self.store.user_add_threepid(
+ user_id, "email", email.value, now_ts, now_ts
+ )
+
+ if request_user.phone_numbers:
+ for phone_number in request_user.phone_numbers:
+ if phone_number.value:
+ await self.store.user_add_threepid(
+ user_id, "msisdn", phone_number.value, now_ts, now_ts
+ )
+
+ if request_user.photos and request_user.photos[0].value:
+ media_repo = (
+ self.hs.get_media_repository()
+ if self.hs.config.media.can_load_media_repo
+ else None
+ )
+ http_client = self.hs.get_proxied_blocklisted_http_client()
+
+ await self.profile_handler.set_avatar_from_http_url(
+ user_id,
+ str(request_user.photos[0].value),
+ media_repo,
+ http_client,
+ "scim_",
+ )
+
+ response_user = await self.get_scim_user(user_id)
+ payload = response_user.model_dump(
+ scim_ctx=Context.RESOURCE_CREATION_RESPONSE
+ )
+ return self.make_response(request, HTTPStatus.CREATED, payload)
+
+ except SynapseError as exc:
+ return self.make_error_response(request, exc.code, exc.msg)
+
+
+class ServiceProviderConfigServlet(SCIMServlet):
+ PATTERNS = [re.compile(f"^{SCIM_PREFIX}/ServiceProviderConfig$")]
+
+ async def on_GET(self, request: SynapseRequest) -> Tuple[int, JsonDict]:
+ """Implement the RFC7644 mandatory ServiceProviderConfig query endpoint.
+
+ As defined in:
+ https://datatracker.ietf.org/doc/html/rfc7644#section-4"""
+
+ spc = ServiceProviderConfig(
+ meta=Meta(
+ resource_type="ServiceProviderConfig",
+ location=(
+ self.config.server.public_baseurl
+ + SCIM_PREFIX[1:]
+ + "/ServiceProviderConfig"
+ ),
+ ),
+ documentation_uri="https://element-hq.github.io/synapse/latest/admin_api/scim_api.html",
+ patch=Patch(supported=False),
+ bulk=Bulk(supported=False, max_operations=0, max_payload_size=0),
+ change_password=ChangePassword(supported=True),
+ filter=Filter(supported=False, max_results=0),
+ sort=Sort(supported=False),
+ etag=ETag(supported=False),
+ authentication_schemes=[
+ AuthenticationScheme(
+ name="OAuth Bearer Token",
+ description="Authentication scheme using the OAuth Bearer Token Standard",
+ spec_uri="http://www.rfc-editor.org/info/rfc6750",
+ documentation_uri="https://element-hq.github.io/synapse/latest/openid.html",
+ type="oauthbearertoken",
+ primary=True,
+ ),
+ ],
+ )
+ return self.make_response(
+ request,
+ HTTPStatus.OK,
+ spc.model_dump(scim_ctx=Context.RESOURCE_QUERY_RESPONSE),
+ )
+
+
+class BaseSchemaServlet(SCIMServlet):
+ schemas: Dict[str, "Schema"]
+
+ def __init__(self, hs: "HomeServer"):
+ super().__init__(hs)
+ self.schemas = {
+ "urn:ietf:params:scim:schemas:core:2.0:ServiceProviderConfig": ServiceProviderConfig.to_schema(),
+ "urn:ietf:params:scim:schemas:core:2.0:ResourceType": ResourceType.to_schema(),
+ "urn:ietf:params:scim:schemas:core:2.0:Schema": Schema.to_schema(),
+ "urn:ietf:params:scim:schemas:core:2.0:User": User.to_schema(),
+ }
+ for schema_id, schema in self.schemas.items():
+ schema_name = schema_id.split(":")[-1]
+ schema.meta = Meta(
+ resource_type=schema_name,
+ location=(
+ self.config.server.public_baseurl
+ + SCIM_PREFIX[1:]
+ + "/Schemas/"
+ + schema_id
+ ),
+ )
+
+
+class SchemaListServlet(BaseSchemaServlet):
+ PATTERNS = [re.compile(f"^{SCIM_PREFIX}/Schemas$")]
+
+ async def on_GET(self, request: SynapseRequest) -> Tuple[int, JsonDict]:
+ """Implement the RFC7644 mandatory Schema list query endpoint.
+
+ As defined in:
+ https://datatracker.ietf.org/doc/html/rfc7644#section-4"""
+
+ req = self.parse_search_request(request)
+ start_index = req.start_index or 0
+ stop_index = start_index + req.count if req.count else None
+ resources = list(self.schemas.values())
+ response = ListResponse[Schema](
+ total_results=len(resources),
+ items_per_page=req.count or len(resources),
+ start_index=start_index,
+ resources=resources[start_index - 1 : stop_index],
+ )
+ return self.make_response(
+ request,
+ HTTPStatus.OK,
+ response.model_dump(scim_ctx=Context.RESOURCE_QUERY_RESPONSE),
+ )
+
+
+class SchemaServlet(BaseSchemaServlet):
+ PATTERNS = [re.compile(f"^{SCIM_PREFIX}/Schemas/(?P[^/]*)$")]
+
+ async def on_GET(
+ self, request: SynapseRequest, schema_id: str
+ ) -> Tuple[int, JsonDict]:
+ """Implement the RFC7644 mandatory Schema query endpoint.
+
+ As defined in:
+ https://datatracker.ietf.org/doc/html/rfc7644#section-4"""
+
+ try:
+ return self.make_response(
+ request,
+ HTTPStatus.OK,
+ self.schemas[schema_id].model_dump(
+ scim_ctx=Context.RESOURCE_QUERY_RESPONSE
+ ),
+ )
+ except KeyError:
+ return self.make_error_response(
+ request, HTTPStatus.NOT_FOUND, "Object not found"
+ )
+
+
+class BaseResourceTypeServlet(SCIMServlet):
+ resource_type: "ResourceType"
+
+ def __init__(self, hs: "HomeServer"):
+ super().__init__(hs)
+ self.resource_type = ResourceType(
+ id="User",
+ name="User",
+ endpoint="/Users",
+ description="User accounts",
+ schema_="urn:ietf:params:scim:schemas:core:2.0:User",
+ meta=Meta(
+ resource_type="ResourceType",
+ location=(
+ self.config.server.public_baseurl
+ + SCIM_PREFIX[1:]
+ + "/ResourceTypes/User"
+ ),
+ ),
+ )
+
+
+class ResourceTypeListServlet(BaseResourceTypeServlet):
+ PATTERNS = [re.compile(f"^{SCIM_PREFIX}/ResourceTypes$")]
+
+ async def on_GET(self, request: SynapseRequest) -> Tuple[int, JsonDict]:
+ """Implement the RFC7644 mandatory ResourceType list query endpoint.
+
+ As defined in:
+ https://datatracker.ietf.org/doc/html/rfc7644#section-4"""
+
+ req = self.parse_search_request(request)
+ start_index = req.start_index or 0
+ stop_index = start_index + req.count if req.count else None
+ resources = [
+ self.resource_type.model_dump(scim_ctx=Context.RESOURCE_QUERY_RESPONSE)
+ ]
+ response = ListResponse[ResourceType](
+ total_results=len(resources),
+ items_per_page=req.count or len(resources),
+ start_index=start_index,
+ resources=resources[start_index - 1 : stop_index],
+ )
+ return self.make_response(
+ request,
+ HTTPStatus.OK,
+ response.model_dump(scim_ctx=Context.RESOURCE_QUERY_RESPONSE),
+ )
+
+
+class ResourceTypeServlet(BaseResourceTypeServlet):
+ PATTERNS = [re.compile(f"^{SCIM_PREFIX}/ResourceTypes/(?P[^/]*)$")]
+
+ async def on_GET(
+ self, request: SynapseRequest, resource_type: str
+ ) -> Tuple[int, JsonDict]:
+ """Implement the RFC7644 mandatory ResourceType query endpoint.
+
+ As defined in:
+ https://datatracker.ietf.org/doc/html/rfc7644#section-4"""
+
+ resource_types = {
+ "User": self.resource_type.model_dump(
+ scim_ctx=Context.RESOURCE_QUERY_RESPONSE
+ ),
+ }
+
+ try:
+ return self.make_response(
+ request, HTTPStatus.OK, resource_types[resource_type]
+ )
+ except KeyError:
+ return self.make_error_response(
+ request, HTTPStatus.NOT_FOUND, "Object not found"
+ )
diff --git a/synapse/util/templates.py b/synapse/util/templates.py
index fc5dbc069c1..b0ab67cfb96 100644
--- a/synapse/util/templates.py
+++ b/synapse/util/templates.py
@@ -23,7 +23,7 @@
import time
import urllib.parse
-from typing import TYPE_CHECKING, Callable, Optional, Sequence, Union
+from typing import TYPE_CHECKING, Callable, Dict, Optional, Sequence, Union
import jinja2
@@ -81,6 +81,33 @@ def build_jinja_env(
return env
+def mxc_to_http(
+ public_baseurl: Optional[str],
+ value: str,
+ params: Optional[Dict] = None,
+) -> str:
+ if not public_baseurl:
+ raise RuntimeError(
+ "public_baseurl must be set in the homeserver config to convert MXC URLs to HTTP URLs."
+ )
+
+ if value[0:6] != "mxc://":
+ return ""
+
+ server_and_media_id = value[6:]
+ fragment = None
+ if "#" in server_and_media_id:
+ server_and_media_id, fragment = server_and_media_id.split("#", 1)
+ fragment = "#" + fragment
+
+ return "%s_matrix/media/v1/thumbnail/%s%s%s" % (
+ public_baseurl,
+ server_and_media_id,
+ "?" + urllib.parse.urlencode(params) if params else "",
+ fragment or "",
+ )
+
+
def _create_mxc_to_http_filter(
public_baseurl: Optional[str],
) -> Callable[[str, int, int, str], str]:
@@ -93,27 +120,8 @@ def _create_mxc_to_http_filter(
def mxc_to_http_filter(
value: str, width: int, height: int, resize_method: str = "crop"
) -> str:
- if not public_baseurl:
- raise RuntimeError(
- "public_baseurl must be set in the homeserver config to convert MXC URLs to HTTP URLs."
- )
-
- if value[0:6] != "mxc://":
- return ""
-
- server_and_media_id = value[6:]
- fragment = None
- if "#" in server_and_media_id:
- server_and_media_id, fragment = server_and_media_id.split("#", 1)
- fragment = "#" + fragment
-
params = {"width": width, "height": height, "method": resize_method}
- return "%s_matrix/media/v1/thumbnail/%s?%s%s" % (
- public_baseurl,
- server_and_media_id,
- urllib.parse.urlencode(params),
- fragment or "",
- )
+ return mxc_to_http(public_baseurl, value, params)
return mxc_to_http_filter
diff --git a/tests/handlers/test_profile.py b/tests/handlers/test_profile.py
index cb1c6fbb801..f41992c0772 100644
--- a/tests/handlers/test_profile.py
+++ b/tests/handlers/test_profile.py
@@ -18,21 +18,25 @@
# [This file includes modifications made by New Vector Limited]
#
#
-from typing import Any, Awaitable, Callable, Dict
+from http import HTTPStatus
+from typing import Any, Awaitable, BinaryIO, Callable, Dict, List, Optional, Tuple
from unittest.mock import AsyncMock, Mock
from parameterized import parameterized
from twisted.test.proto_helpers import MemoryReactor
+from twisted.web.http_headers import Headers
import synapse.types
-from synapse.api.errors import AuthError, SynapseError
+from synapse.api.errors import AuthError, Codes, SynapseError
+from synapse.http.client import RawHeaders
from synapse.rest import admin
from synapse.server import HomeServer
from synapse.types import JsonDict, UserID
from synapse.util import Clock
from tests import unittest
+from tests.test_utils import SMALL_PNG, FakeResponse
class ProfileTestCase(unittest.HomeserverTestCase):
@@ -46,6 +50,10 @@ def make_homeserver(self, reactor: MemoryReactor, clock: Clock) -> HomeServer:
self.query_handlers: Dict[str, Callable[[dict], Awaitable[JsonDict]]] = {}
+ self.http_client = Mock(spec=["get_file"])
+ self.http_client.get_file.side_effect = mock_get_file
+ self.http_client.user_agent = b"Synapse Test"
+
def register_query_handler(
query_type: str, handler: Callable[[dict], Awaitable[JsonDict]]
) -> None:
@@ -57,7 +65,13 @@ def register_query_handler(
federation_client=self.mock_federation,
federation_server=Mock(),
federation_registry=self.mock_registry,
+ proxied_blocklisted_http_client=self.http_client,
+ )
+
+ self.media_repo = (
+ hs.get_media_repository() if hs.config.media.can_load_media_repo else None
)
+
return hs
def prepare(self, reactor: MemoryReactor, clock: Clock, hs: HomeServer) -> None:
@@ -398,3 +412,143 @@ def _setup_local_files(self, names_and_props: Dict[str, Dict[str, Any]]) -> None
user_id=UserID.from_string("@rin:test"),
)
)
+
+ def test_set_avatar_from_http_url(self) -> None:
+ """Tests successfully setting the avatar of a newly created user"""
+ # Create a new user to set avatar for
+ reg_handler = self.hs.get_registration_handler()
+ user_id = self.get_success(reg_handler.register_user(approved=True))
+
+ self.assertTrue(
+ self.get_success(
+ self.handler.set_avatar_from_http_url(
+ user_id,
+ "http://my.server/me.png",
+ self.media_repo,
+ self.http_client,
+ "sso_avatar_",
+ )
+ )
+ )
+
+ # Ensure avatar is set on this newly created user,
+ # so no need to compare for the exact image
+ profile_handler = self.hs.get_profile_handler()
+ profile = self.get_success(profile_handler.get_profile(user_id))
+ self.assertIsNot(profile["avatar_url"], None)
+
+ @unittest.override_config({"max_avatar_size": 1})
+ def test_set_avatar_too_big_image(self) -> None:
+ """Tests that saving an avatar fails when it is too big"""
+ # any random user works since image check is supposed to fail
+ user_id = "@sso-user:test"
+
+ self.assertFalse(
+ self.get_success(
+ self.handler.set_avatar_from_http_url(
+ user_id,
+ "http://my.server/me.png",
+ self.media_repo,
+ self.http_client,
+ "sso_avatar_",
+ )
+ )
+ )
+
+ @unittest.override_config({"allowed_avatar_mimetypes": ["image/jpeg"]})
+ def test_set_avatar_incorrect_mime_type(self) -> None:
+ """Tests that saving an avatar fails when its mime type is not allowed"""
+ # any random user works since image check is supposed to fail
+ user_id = "@sso-user:test"
+
+ self.assertFalse(
+ self.get_success(
+ self.handler.set_avatar_from_http_url(
+ user_id,
+ "http://my.server/me.png",
+ self.media_repo,
+ self.http_client,
+ "sso_avatar_",
+ )
+ )
+ )
+
+ def test_skip_saving_avatar_when_not_changed(self) -> None:
+ """Tests whether saving of avatar correctly skips if the avatar hasn't
+ changed"""
+ # Create a new user to set avatar for
+ reg_handler = self.hs.get_registration_handler()
+ user_id = self.get_success(reg_handler.register_user(approved=True))
+
+ # set avatar for the first time, should be a success
+ self.assertTrue(
+ self.get_success(
+ self.handler.set_avatar_from_http_url(
+ user_id,
+ "http://my.server/me.png",
+ self.media_repo,
+ self.http_client,
+ "sso_avatar_",
+ )
+ )
+ )
+
+ # get avatar picture for comparison after another attempt
+ profile_handler = self.hs.get_profile_handler()
+ profile = self.get_success(profile_handler.get_profile(user_id))
+ url_to_match = profile["avatar_url"]
+
+ # set same avatar for the second time, should be a success
+ self.assertTrue(
+ self.get_success(
+ self.handler.set_avatar_from_http_url(
+ user_id,
+ "http://my.server/me.png",
+ self.media_repo,
+ self.http_client,
+ "sso_avatar_",
+ )
+ )
+ )
+
+ # compare avatar picture's url from previous step
+ profile = self.get_success(profile_handler.get_profile(user_id))
+ self.assertEqual(profile["avatar_url"], url_to_match)
+
+
+async def mock_get_file(
+ url: str,
+ output_stream: BinaryIO,
+ max_size: Optional[int] = None,
+ headers: Optional[RawHeaders] = None,
+ is_allowed_content_type: Optional[Callable[[str], bool]] = None,
+) -> Tuple[int, Dict[bytes, List[bytes]], str, int]:
+ fake_response = FakeResponse(code=404)
+ if url == "http://my.server/me.png":
+ fake_response = FakeResponse(
+ code=200,
+ headers=Headers(
+ {"Content-Type": ["image/png"], "Content-Length": [str(len(SMALL_PNG))]}
+ ),
+ body=SMALL_PNG,
+ )
+
+ if max_size is not None and max_size < len(SMALL_PNG):
+ raise SynapseError(
+ HTTPStatus.BAD_GATEWAY,
+ "Requested file is too large > %r bytes" % (max_size,),
+ Codes.TOO_LARGE,
+ )
+
+ if is_allowed_content_type and not is_allowed_content_type("image/png"):
+ raise SynapseError(
+ HTTPStatus.BAD_GATEWAY,
+ (
+ "Requested file's content type not allowed for this operation: %s"
+ % "image/png"
+ ),
+ )
+
+ output_stream.write(fake_response.body)
+
+ return len(SMALL_PNG), {b"Content-Type": [b"image/png"]}, "", 200
diff --git a/tests/handlers/test_sso.py b/tests/handlers/test_sso.py
deleted file mode 100644
index 25e9130aaf0..00000000000
--- a/tests/handlers/test_sso.py
+++ /dev/null
@@ -1,152 +0,0 @@
-#
-# This file is licensed under the Affero General Public License (AGPL) version 3.
-#
-# Copyright (C) 2023 New Vector, Ltd
-#
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as
-# published by the Free Software Foundation, either version 3 of the
-# License, or (at your option) any later version.
-#
-# See the GNU Affero General Public License for more details:
-# .
-#
-# Originally licensed under the Apache License, Version 2.0:
-# .
-#
-# [This file includes modifications made by New Vector Limited]
-#
-#
-from http import HTTPStatus
-from typing import BinaryIO, Callable, Dict, List, Optional, Tuple
-from unittest.mock import Mock
-
-from twisted.test.proto_helpers import MemoryReactor
-from twisted.web.http_headers import Headers
-
-from synapse.api.errors import Codes, SynapseError
-from synapse.http.client import RawHeaders
-from synapse.server import HomeServer
-from synapse.util import Clock
-
-from tests import unittest
-from tests.test_utils import SMALL_PNG, FakeResponse
-
-
-class TestSSOHandler(unittest.HomeserverTestCase):
- def make_homeserver(self, reactor: MemoryReactor, clock: Clock) -> HomeServer:
- self.http_client = Mock(spec=["get_file"])
- self.http_client.get_file.side_effect = mock_get_file
- self.http_client.user_agent = b"Synapse Test"
- hs = self.setup_test_homeserver(
- proxied_blocklisted_http_client=self.http_client
- )
- return hs
-
- def test_set_avatar(self) -> None:
- """Tests successfully setting the avatar of a newly created user"""
- handler = self.hs.get_sso_handler()
-
- # Create a new user to set avatar for
- reg_handler = self.hs.get_registration_handler()
- user_id = self.get_success(reg_handler.register_user(approved=True))
-
- self.assertTrue(
- self.get_success(handler.set_avatar(user_id, "http://my.server/me.png"))
- )
-
- # Ensure avatar is set on this newly created user,
- # so no need to compare for the exact image
- profile_handler = self.hs.get_profile_handler()
- profile = self.get_success(profile_handler.get_profile(user_id))
- self.assertIsNot(profile["avatar_url"], None)
-
- @unittest.override_config({"max_avatar_size": 1})
- def test_set_avatar_too_big_image(self) -> None:
- """Tests that saving an avatar fails when it is too big"""
- handler = self.hs.get_sso_handler()
-
- # any random user works since image check is supposed to fail
- user_id = "@sso-user:test"
-
- self.assertFalse(
- self.get_success(handler.set_avatar(user_id, "http://my.server/me.png"))
- )
-
- @unittest.override_config({"allowed_avatar_mimetypes": ["image/jpeg"]})
- def test_set_avatar_incorrect_mime_type(self) -> None:
- """Tests that saving an avatar fails when its mime type is not allowed"""
- handler = self.hs.get_sso_handler()
-
- # any random user works since image check is supposed to fail
- user_id = "@sso-user:test"
-
- self.assertFalse(
- self.get_success(handler.set_avatar(user_id, "http://my.server/me.png"))
- )
-
- def test_skip_saving_avatar_when_not_changed(self) -> None:
- """Tests whether saving of avatar correctly skips if the avatar hasn't
- changed"""
- handler = self.hs.get_sso_handler()
-
- # Create a new user to set avatar for
- reg_handler = self.hs.get_registration_handler()
- user_id = self.get_success(reg_handler.register_user(approved=True))
-
- # set avatar for the first time, should be a success
- self.assertTrue(
- self.get_success(handler.set_avatar(user_id, "http://my.server/me.png"))
- )
-
- # get avatar picture for comparison after another attempt
- profile_handler = self.hs.get_profile_handler()
- profile = self.get_success(profile_handler.get_profile(user_id))
- url_to_match = profile["avatar_url"]
-
- # set same avatar for the second time, should be a success
- self.assertTrue(
- self.get_success(handler.set_avatar(user_id, "http://my.server/me.png"))
- )
-
- # compare avatar picture's url from previous step
- profile = self.get_success(profile_handler.get_profile(user_id))
- self.assertEqual(profile["avatar_url"], url_to_match)
-
-
-async def mock_get_file(
- url: str,
- output_stream: BinaryIO,
- max_size: Optional[int] = None,
- headers: Optional[RawHeaders] = None,
- is_allowed_content_type: Optional[Callable[[str], bool]] = None,
-) -> Tuple[int, Dict[bytes, List[bytes]], str, int]:
- fake_response = FakeResponse(code=404)
- if url == "http://my.server/me.png":
- fake_response = FakeResponse(
- code=200,
- headers=Headers(
- {"Content-Type": ["image/png"], "Content-Length": [str(len(SMALL_PNG))]}
- ),
- body=SMALL_PNG,
- )
-
- if max_size is not None and max_size < len(SMALL_PNG):
- raise SynapseError(
- HTTPStatus.BAD_GATEWAY,
- "Requested file is too large > %r bytes" % (max_size,),
- Codes.TOO_LARGE,
- )
-
- if is_allowed_content_type and not is_allowed_content_type("image/png"):
- raise SynapseError(
- HTTPStatus.BAD_GATEWAY,
- (
- "Requested file's content type not allowed for this operation: %s"
- % "image/png"
- ),
- )
-
- output_stream.write(fake_response.body)
-
- return len(SMALL_PNG), {b"Content-Type": [b"image/png"]}, "", 200
diff --git a/tests/rest/test_scim.py b/tests/rest/test_scim.py
new file mode 100644
index 00000000000..05f695a0fc9
--- /dev/null
+++ b/tests/rest/test_scim.py
@@ -0,0 +1,721 @@
+from unittest import mock
+from unittest.mock import Mock
+
+from twisted.test.proto_helpers import MemoryReactor
+
+import synapse.rest.admin
+import synapse.rest.scim
+from synapse.config import ConfigError
+from synapse.config.homeserver import HomeServerConfig
+from synapse.rest.client import login
+from synapse.rest.scim import HAS_SCIM2, SCIM_DEFAULT_IDP_ID
+from synapse.server import HomeServer
+from synapse.types import JsonDict, UserID
+from synapse.util import Clock
+
+from tests.handlers.test_profile import mock_get_file
+from tests.unittest import HomeserverTestCase, skip_unless
+from tests.utils import default_config
+
+
+@skip_unless(HAS_SCIM2, "requires scim2-models")
+class SCIMExperimentalFeatureTestCase(HomeserverTestCase):
+ servlets = [
+ synapse.rest.admin.register_servlets_for_client_rest_resource,
+ synapse.rest.scim.register_scim_servlets,
+ login.register_servlets,
+ ]
+ url = "/_synapse/admin/scim/v2"
+
+ def prepare(self, reactor: MemoryReactor, clock: Clock, hs: HomeServer) -> None:
+ self.store = hs.get_datastores().main
+
+ self.admin_user_id = self.register_user(
+ "admin", "pass", admin=True, displayname="admin display name"
+ )
+ self.admin_user_tok = self.login("admin", "pass")
+ self.user_user_id = self.register_user(
+ "user", "pass", admin=False, displayname="user display name"
+ )
+
+ def test_disabled_by_default(self) -> None:
+ """
+ Without explicitly enabled by configuration, the SCIM API endpoint should be
+ disabled.
+ """
+ channel = self.make_request(
+ "GET",
+ f"{self.url}/Users/{self.user_user_id}",
+ access_token=self.admin_user_tok,
+ )
+ self.assertEqual(404, channel.code, msg=channel.json_body)
+
+ def test_exclusive_with_msc3861(self) -> None:
+ """
+ Without explicitly enabled by configuration, the SCIM API endpoint should be
+ disabled.
+ """
+
+ config_dict = {
+ "experimental_features": {
+ "msc4098": {
+ "enabled": True,
+ },
+ "msc3861": {"enabled": True},
+ },
+ **default_config("test"),
+ }
+
+ with self.assertRaises(ConfigError):
+ config = HomeServerConfig()
+ config.parse_config_dict(config_dict, "", "")
+
+
+@skip_unless(HAS_SCIM2, "requires scim2-models")
+class UserProvisioningTestCase(HomeserverTestCase):
+ servlets = [
+ synapse.rest.admin.register_servlets_for_client_rest_resource,
+ synapse.rest.scim.register_scim_servlets,
+ login.register_servlets,
+ ]
+ url = "/_synapse/admin/scim/v2"
+
+ def make_homeserver(self, reactor: MemoryReactor, clock: Clock) -> HomeServer:
+ self.http_client = Mock(spec=["get_file"])
+ self.http_client.get_file.side_effect = mock_get_file
+ self.http_client.user_agent = b"Synapse Test"
+
+ hs = self.setup_test_homeserver(
+ proxied_blocklisted_http_client=self.http_client,
+ )
+ return hs
+
+ def default_config(self) -> JsonDict:
+ conf = super().default_config()
+ msc4098_conf = conf.setdefault("experimental_features", {}).setdefault(
+ "msc4098", {}
+ )
+ msc4098_conf.setdefault("enabled", True)
+ return conf
+
+ def prepare(self, reactor: MemoryReactor, clock: Clock, hs: HomeServer) -> None:
+ self.store = hs.get_datastores().main
+
+ self.admin_user_id = self.register_user(
+ "admin", "pass", admin=True, displayname="admin display name"
+ )
+ self.admin_user_tok = self.login("admin", "pass")
+ self.user_user_id = self.register_user(
+ "user", "pass", admin=False, displayname="user display name"
+ )
+ self.other_user_ids = [
+ self.register_user(f"user{i:02d}", "pass", displayname=f"user{i}")
+ for i in range(15)
+ ]
+ self.get_success(
+ self.store.user_add_threepid(
+ self.user_user_id, "email", "user@mydomain.tld", 0, 0
+ )
+ )
+ self.get_success(
+ self.store.user_add_threepid(
+ self.user_user_id, "msisdn", "+1-12345678", 1, 1
+ )
+ )
+ self.get_success(
+ self.store.set_profile_avatar_url(
+ UserID.from_string(self.user_user_id),
+ "mxc://servername/mediaid",
+ )
+ )
+ self.get_success(
+ self.store.record_user_external_id(
+ SCIM_DEFAULT_IDP_ID, "IDP-user", self.user_user_id
+ )
+ )
+
+ def test_get_user(self) -> None:
+ """
+ Nominal test of the /Users/ endpoint.
+ """
+ channel = self.make_request(
+ "GET",
+ f"{self.url}/Users/{self.user_user_id}",
+ access_token=self.admin_user_tok,
+ )
+
+ self.assertEqual(200, channel.code, msg=channel.json_body)
+ self.assertEqual(
+ {
+ "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"],
+ "meta": {
+ "resourceType": "User",
+ "created": mock.ANY,
+ "lastModified": mock.ANY,
+ "location": "https://test/_synapse/admin/scim/v2/Users/@user:test",
+ },
+ "id": "@user:test",
+ "userName": "user",
+ "externalId": "IDP-user",
+ "phoneNumbers": [{"value": "+1-12345678"}],
+ "emails": [{"value": "user@mydomain.tld"}],
+ "active": True,
+ "displayName": "user display name",
+ "photos": [
+ {
+ "type": "photo",
+ "primary": True,
+ "value": "https://test/_matrix/media/v1/thumbnail/servername/mediaid",
+ }
+ ],
+ },
+ channel.json_body,
+ )
+ self.assertIn(
+ b"application/scim+json",
+ channel.headers.getRawHeaders(b"Content-Type") or [],
+ )
+
+ def test_get_user_include_attribute(self) -> None:
+ """
+ Nominal test of the /Users/ endpoint with attribute inclusion arguments.
+ """
+ channel = self.make_request(
+ "GET",
+ f"{self.url}/Users/{self.user_user_id}?attributes=userName",
+ access_token=self.admin_user_tok,
+ )
+
+ self.assertEqual(200, channel.code, msg=channel.json_body)
+ self.assertEqual(
+ {
+ "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"],
+ "id": "@user:test",
+ "userName": "user",
+ },
+ channel.json_body,
+ )
+
+ def test_get_user_exclude_attribute(self) -> None:
+ """
+ Nominal test of the /Users/ endpoint with attribute exclusion arguments.
+ """
+ channel = self.make_request(
+ "GET",
+ f"{self.url}/Users/{self.user_user_id}?excludedAttributes=userName",
+ access_token=self.admin_user_tok,
+ )
+
+ self.assertEqual(200, channel.code, msg=channel.json_body)
+ self.assertEqual(
+ {
+ "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"],
+ "meta": {
+ "resourceType": "User",
+ "created": mock.ANY,
+ "lastModified": mock.ANY,
+ "location": "https://test/_synapse/admin/scim/v2/Users/@user:test",
+ },
+ "id": "@user:test",
+ "externalId": "IDP-user",
+ "phoneNumbers": [{"value": "+1-12345678"}],
+ "emails": [{"value": "user@mydomain.tld"}],
+ "active": True,
+ "displayName": "user display name",
+ "photos": [
+ {
+ "type": "photo",
+ "primary": True,
+ "value": "https://test/_matrix/media/v1/thumbnail/servername/mediaid",
+ }
+ ],
+ },
+ channel.json_body,
+ )
+
+ def test_get_users(self) -> None:
+ """
+ Nominal test of the /Users endpoint
+ """
+ channel = self.make_request(
+ "GET",
+ f"{self.url}/Users",
+ access_token=self.admin_user_tok,
+ )
+
+ self.assertEqual(
+ channel.json_body["schemas"],
+ ["urn:ietf:params:scim:api:messages:2.0:ListResponse"],
+ )
+ self.assertEqual(len(channel.json_body["Resources"]), 17)
+
+ self.assertTrue(
+ {
+ "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"],
+ "meta": {
+ "resourceType": "User",
+ "created": mock.ANY,
+ "lastModified": mock.ANY,
+ "location": "https://test/_synapse/admin/scim/v2/Users/@user:test",
+ },
+ "id": "@user:test",
+ "userName": "user",
+ "externalId": "IDP-user",
+ "phoneNumbers": [{"value": "+1-12345678"}],
+ "emails": [{"value": "user@mydomain.tld"}],
+ "active": True,
+ "displayName": "user display name",
+ "photos": [
+ {
+ "type": "photo",
+ "primary": True,
+ "value": "https://test/_matrix/media/v1/thumbnail/servername/mediaid",
+ }
+ ],
+ }
+ in channel.json_body["Resources"],
+ )
+
+ def test_get_users_pagination_count(self) -> None:
+ """
+ Test the 'count' parameter of the /Users endpoint.
+ """
+ channel = self.make_request(
+ "GET",
+ f"{self.url}/Users?count=2",
+ access_token=self.admin_user_tok,
+ )
+
+ self.assertEqual(
+ channel.json_body["schemas"],
+ ["urn:ietf:params:scim:api:messages:2.0:ListResponse"],
+ )
+ self.assertEqual(len(channel.json_body["Resources"]), 2)
+
+ def test_get_users_pagination_start_index(self) -> None:
+ """
+ Test the 'startIndex' parameter of the /Users endpoint
+ """
+ channel = self.make_request(
+ "GET",
+ f"{self.url}/Users?startIndex=2&count=1",
+ access_token=self.admin_user_tok,
+ )
+
+ self.assertEqual(
+ channel.json_body["schemas"],
+ ["urn:ietf:params:scim:api:messages:2.0:ListResponse"],
+ )
+ self.assertEqual(len(channel.json_body["Resources"]), 1)
+ self.assertEqual(channel.json_body["Resources"][0]["id"], "@user00:test")
+
+ def test_get_users_pagination_big_start_index(self) -> None:
+ """
+ Test the 'startIndex' parameter of the /Users endpoint
+ is not greater than the number of users.
+ """
+ channel = self.make_request(
+ "GET",
+ f"{self.url}/Users?startIndex=1234",
+ access_token=self.admin_user_tok,
+ )
+
+ self.assertEqual(
+ channel.json_body["schemas"],
+ ["urn:ietf:params:scim:api:messages:2.0:ListResponse"],
+ )
+ self.assertEqual(
+ 0,
+ len(channel.json_body["Resources"]),
+ )
+ self.assertEqual(
+ 17,
+ channel.json_body["totalResults"],
+ )
+
+ def test_get_invalid_user(self) -> None:
+ """
+ Attempt to retrieve user information with a wrong username.
+ """
+ channel = self.make_request(
+ "GET",
+ f"{self.url}/Users/@bjensen:test",
+ access_token=self.admin_user_tok,
+ )
+
+ self.assertEqual(404, channel.code, msg=channel.json_body)
+ self.assertEqual(
+ ["urn:ietf:params:scim:api:messages:2.0:Error"],
+ channel.json_body["schemas"],
+ )
+
+ def test_post_user(self) -> None:
+ """
+ Create a new user.
+ """
+ request_data: JsonDict = {
+ "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"],
+ "userName": "bjensen",
+ "externalId": "IDP-bjensen",
+ "phoneNumbers": [{"value": "+1-12345678"}],
+ "emails": [{"value": "bjensen@mydomain.tld"}],
+ "photos": [
+ {
+ "type": "photo",
+ "primary": True,
+ "value": "http://my.server/me.png",
+ }
+ ],
+ "active": True,
+ "displayName": "bjensen display name",
+ "password": "correct horse battery staple",
+ }
+ channel = self.make_request(
+ "POST",
+ f"{self.url}/Users/",
+ request_data,
+ access_token=self.admin_user_tok,
+ )
+ self.assertEqual(201, channel.code, msg=channel.json_body)
+
+ expected = {
+ "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"],
+ "meta": {
+ "resourceType": "User",
+ "created": mock.ANY,
+ "lastModified": mock.ANY,
+ "location": "https://test/_synapse/admin/scim/v2/Users/@bjensen:test",
+ },
+ "id": "@bjensen:test",
+ "externalId": "IDP-bjensen",
+ "phoneNumbers": [{"value": "+1-12345678"}],
+ "userName": "bjensen",
+ "emails": [{"value": "bjensen@mydomain.tld"}],
+ "active": True,
+ "photos": [
+ {
+ "type": "photo",
+ "primary": True,
+ "value": mock.ANY,
+ }
+ ],
+ "displayName": "bjensen display name",
+ }
+ self.assertEqual(expected, channel.json_body)
+ self.assertSubstring(
+ "https://test/_matrix/media/v1/thumbnail/test/",
+ channel.json_body["photos"][0]["value"],
+ )
+
+ channel = self.make_request(
+ "GET",
+ f"{self.url}/Users/@bjensen:test",
+ access_token=self.admin_user_tok,
+ )
+
+ self.assertEqual(200, channel.code, msg=channel.json_body)
+ self.assertEqual(expected, channel.json_body)
+
+ def test_delete_user(self) -> None:
+ """
+ Delete an existing user.
+ """
+ channel = self.make_request(
+ "DELETE",
+ f"{self.url}/Users/@user:test",
+ access_token=self.admin_user_tok,
+ )
+ self.assertEqual(204, channel.code)
+ self.assertTrue(self.store.is_user_erased("@user:test"))
+
+ def test_delete_invalid_user(self) -> None:
+ """
+ Attempt to delete a user with a non-existing username.
+ """
+
+ channel = self.make_request(
+ "GET",
+ f"{self.url}/Users/@bjensen:test",
+ access_token=self.admin_user_tok,
+ )
+ self.assertEqual(404, channel.code)
+ self.assertEqual(
+ ["urn:ietf:params:scim:api:messages:2.0:Error"],
+ channel.json_body["schemas"],
+ )
+
+ def test_replace_user(self) -> None:
+ """
+ Replace user information.
+ """
+ channel = self.make_request(
+ "GET",
+ f"{self.url}/Users/@user:test",
+ access_token=self.admin_user_tok,
+ )
+
+ self.assertEqual(200, channel.code, msg=channel.json_body)
+ self.assertEqual(
+ {
+ "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"],
+ "meta": {
+ "resourceType": "User",
+ "created": mock.ANY,
+ "lastModified": mock.ANY,
+ "location": "https://test/_synapse/admin/scim/v2/Users/@user:test",
+ },
+ "id": "@user:test",
+ "userName": "user",
+ "externalId": "IDP-user",
+ "phoneNumbers": [{"value": "+1-12345678"}],
+ "emails": [{"value": "user@mydomain.tld"}],
+ "photos": [
+ {
+ "type": "photo",
+ "primary": True,
+ "value": "https://test/_matrix/media/v1/thumbnail/servername/mediaid",
+ }
+ ],
+ "active": True,
+ "displayName": "user display name",
+ },
+ channel.json_body,
+ )
+
+ request_data: JsonDict = {
+ "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"],
+ "externalId": "IDP-user",
+ "phoneNumbers": [{"value": "+1-11112222"}],
+ "emails": [{"value": "newmail@mydomain.tld"}],
+ "userName": "user",
+ "displayName": "new display name",
+ "photos": [
+ {
+ "type": "photo",
+ "primary": True,
+ "value": "http://my.server/me.png",
+ }
+ ],
+ }
+
+ channel = self.make_request(
+ "PUT",
+ f"{self.url}/Users/@user:test",
+ request_data,
+ access_token=self.admin_user_tok,
+ )
+ self.assertEqual(200, channel.code)
+
+ expected = {
+ "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"],
+ "meta": {
+ "resourceType": "User",
+ "created": mock.ANY,
+ "lastModified": mock.ANY,
+ "location": "https://test/_synapse/admin/scim/v2/Users/@user:test",
+ },
+ "id": "@user:test",
+ "externalId": "IDP-user",
+ "phoneNumbers": [{"value": "+1-11112222"}],
+ "userName": "user",
+ "emails": [{"value": "newmail@mydomain.tld"}],
+ "active": True,
+ "displayName": "new display name",
+ "photos": [
+ {
+ "type": "photo",
+ "primary": True,
+ "value": mock.ANY,
+ }
+ ],
+ }
+ self.assertEqual(expected, channel.json_body)
+ self.assertSubstring(
+ "https://test/_matrix/media/v1/thumbnail/test/",
+ channel.json_body["photos"][0]["value"],
+ )
+
+ channel = self.make_request(
+ "GET",
+ f"{self.url}/Users/@user:test",
+ access_token=self.admin_user_tok,
+ )
+
+ self.assertEqual(200, channel.code, msg=channel.json_body)
+ self.assertEqual(expected, channel.json_body)
+
+ def test_replace_invalid_user(self) -> None:
+ """
+ Attempt to replace user information based on a wrong username.
+ """
+ request_data: JsonDict = {
+ "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"],
+ "phoneNumbers": [{"value": "+1-11112222"}],
+ "emails": [{"value": "newmail@mydomain.tld"}],
+ "userName": "user",
+ "displayName": "new display name",
+ "photos": [
+ {
+ "type": "photo",
+ "primary": True,
+ "value": "http://my.server/me.png",
+ }
+ ],
+ }
+
+ channel = self.make_request(
+ "PUT",
+ f"{self.url}/Users/@bjensen:test",
+ request_data,
+ access_token=self.admin_user_tok,
+ )
+ self.assertEqual(404, channel.code)
+ self.assertEqual(
+ ["urn:ietf:params:scim:api:messages:2.0:Error"],
+ channel.json_body["schemas"],
+ )
+
+
+@skip_unless(HAS_SCIM2, "requires scim2-models")
+class SCIMMetadataTestCase(HomeserverTestCase):
+ servlets = [
+ synapse.rest.admin.register_servlets_for_client_rest_resource,
+ synapse.rest.scim.register_scim_servlets,
+ login.register_servlets,
+ ]
+ url = "/_synapse/admin/scim/v2"
+
+ def default_config(self) -> JsonDict:
+ conf = super().default_config()
+ msc4098_conf = conf.setdefault("experimental_features", {}).setdefault(
+ "msc4098", {}
+ )
+ msc4098_conf.setdefault("enabled", True)
+ return conf
+
+ def prepare(self, reactor: MemoryReactor, clock: Clock, hs: HomeServer) -> None:
+ self.store = hs.get_datastores().main
+
+ self.admin_user_id = self.register_user(
+ "admin", "pass", admin=True, displayname="admin display name"
+ )
+ self.admin_user_tok = self.login("admin", "pass")
+ self.schemas = [
+ "urn:ietf:params:scim:schemas:core:2.0:User",
+ "urn:ietf:params:scim:schemas:core:2.0:ServiceProviderConfig",
+ "urn:ietf:params:scim:schemas:core:2.0:Schema",
+ "urn:ietf:params:scim:schemas:core:2.0:ResourceType",
+ ]
+
+ def test_get_schemas(self) -> None:
+ """
+ Read the /Schemas endpoint
+ """
+ channel = self.make_request(
+ "GET",
+ f"{self.url}/Schemas",
+ access_token=self.admin_user_tok,
+ )
+
+ self.assertEqual(200, channel.code, msg=channel.json_body)
+ self.assertEqual(
+ channel.json_body["schemas"],
+ ["urn:ietf:params:scim:api:messages:2.0:ListResponse"],
+ )
+
+ for schema in self.schemas:
+ self.assertTrue(
+ any(item["id"] == schema for item in channel.json_body["Resources"])
+ )
+
+ def test_get_schema(self) -> None:
+ """
+ Read the /Schemas/ endpoint
+ """
+ for schema in self.schemas:
+ channel = self.make_request(
+ "GET",
+ f"{self.url}/Schemas/{schema}",
+ access_token=self.admin_user_tok,
+ )
+ self.assertEqual(200, channel.code, msg=channel.json_body)
+ self.assertEqual(channel.json_body["id"], schema)
+
+ def test_get_invalid_schema(self) -> None:
+ """
+ Read the /Schemas endpoint
+ """
+ channel = self.make_request(
+ "GET",
+ f"{self.url}/Schemas/urn:ietf:params:scim:schemas:core:2.0:Group",
+ access_token=self.admin_user_tok,
+ )
+ self.assertEqual(404, channel.code, msg=channel.json_body)
+ self.assertEqual(
+ ["urn:ietf:params:scim:api:messages:2.0:Error"],
+ channel.json_body["schemas"],
+ )
+
+ def test_get_service_provider_config(self) -> None:
+ """
+ Read the /ServiceProviderConfig endpoint
+ """
+ channel = self.make_request(
+ "GET",
+ f"{self.url}/ServiceProviderConfig",
+ access_token=self.admin_user_tok,
+ )
+
+ self.assertEqual(200, channel.code, msg=channel.json_body)
+ self.assertEqual(
+ channel.json_body["schemas"],
+ ["urn:ietf:params:scim:schemas:core:2.0:ServiceProviderConfig"],
+ )
+
+ def test_get_resource_types(self) -> None:
+ """
+ Read the /ResourceTypes endpoint
+ """
+ channel = self.make_request(
+ "GET",
+ f"{self.url}/ResourceTypes",
+ access_token=self.admin_user_tok,
+ )
+
+ self.assertEqual(200, channel.code, msg=channel.json_body)
+ self.assertEqual(
+ channel.json_body["schemas"],
+ ["urn:ietf:params:scim:api:messages:2.0:ListResponse"],
+ )
+
+ def test_get_resource_type_user(self) -> None:
+ """
+ Read the /ResourceTypes/User endpoint
+ """
+ channel = self.make_request(
+ "GET",
+ f"{self.url}/ResourceTypes/User",
+ access_token=self.admin_user_tok,
+ )
+
+ self.assertEqual(200, channel.code, msg=channel.json_body)
+ self.assertEqual(
+ channel.json_body["schemas"],
+ ["urn:ietf:params:scim:schemas:core:2.0:ResourceType"],
+ )
+
+ def test_get_invalid_resource_type(self) -> None:
+ """
+ Read an invalid /ResourceTypes/ endpoint
+ """
+ channel = self.make_request(
+ "GET",
+ f"{self.url}/ResourceTypes/Group",
+ access_token=self.admin_user_tok,
+ )
+
+ self.assertEqual(404, channel.code, msg=channel.json_body)
+ self.assertEqual(
+ ["urn:ietf:params:scim:api:messages:2.0:Error"],
+ channel.json_body["schemas"],
+ )