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"], + )