diff --git a/changelog.d/17144.feature b/changelog.d/17144.feature new file mode 100644 index 00000000000..3184b62a76c --- /dev/null +++ b/changelog.d/17144.feature @@ -0,0 +1 @@ +Add support for MSC4098 (SCIM provisioning protocol). diff --git a/docs/SUMMARY.md b/docs/SUMMARY.md index c50121d5f78..950a2183787 100644 --- a/docs/SUMMARY.md +++ b/docs/SUMMARY.md @@ -70,6 +70,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..2485a043285 --- /dev/null +++ b/docs/usage/administration/admin_api/scim_api.md @@ -0,0 +1,257 @@ +# SCIM API + +Synapse implement 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 `/_matrix/client/unstable/coop.yaal/scim`. + +## Installation + +SCIM support for Synapse requires python 3.9+. The `matrix-synapse` package should be installed with the `scim` extra. + +## Examples + +### Create user + +#### Request + +``` +POST /_matrix/client/unstable/coop.yaal/scim/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/_matrix/client/unstable/coop.yaal/scim/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 /_matrix/client/unstable/coop.yaal/scim/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/_matrix/client/unstable/coop.yaal/scim/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 /_matrix/client/unstable/coop.yaal/scim/Users?startIndex=10&count=1 +``` + +#### 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/_matrix/client/unstable/coop.yaal/scim/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 /_matrix/client/unstable/coop.yaal/scim/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/_matrix/client/unstable/coop.yaal/scim/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 + +#### Request + +``` +DELETE /_matrix/client/unstable/coop.yaal/scim/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 d476973ead7..6143c1a12d5 100644 --- a/poetry.lock +++ b/poetry.lock @@ -483,6 +483,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.6.1" +description = "DNS toolkit" +optional = true +python-versions = ">=3.8" +files = [ + {file = "dnspython-2.6.1-py3-none-any.whl", hash = "sha256:5ef3b9680161f6fa89daf8ad451b5f1a33b18ae8a1c6778cdf4b43f08c0a6e50"}, + {file = "dnspython-2.6.1.tar.gz", hash = "sha256:e8f0f9c23a7b7cb99ded64e6c3a6f3e701d78f50c55e002b839dea7225cff7cc"}, +] + +[package.extras] +dev = ["black (>=23.1.0)", "coverage (>=7.0)", "flake8 (>=7)", "mypy (>=1.8)", "pylint (>=3)", "pytest (>=7.4)", "pytest-cov (>=4.1.0)", "sphinx (>=7.2.0)", "twine (>=4.0.0)", "wheel (>=0.42.0)"] +dnssec = ["cryptography (>=41)"] +doh = ["h2 (>=4.1.0)", "httpcore (>=1.0.0)", "httpx (>=0.26.0)"] +doq = ["aioquic (>=0.9.25)"] +idna = ["idna (>=3.6)"] +trio = ["trio (>=0.23)"] +wmi = ["wmi (>=1.5.1)"] + [[package]] name = "docutils" version = "0.19" @@ -508,6 +528,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" @@ -1770,6 +1805,7 @@ files = [ [package.dependencies] annotated-types = ">=0.4.0" +email-validator = {version = ">=2.0.0", optional = true, markers = "extra == \"email\""} pydantic-core = "2.20.1" typing-extensions = [ {version = ">=4.12.2", markers = "python_version >= \"3.13\""}, @@ -2099,6 +2135,7 @@ files = [ {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:69b023b2b4daa7548bcfbd4aa3da05b3a74b772db9e23b982788168117739938"}, {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:81e0b275a9ecc9c0c0c07b4b90ba548307583c125f54d5b6946cfee6360c733d"}, {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba336e390cd8e4d1739f42dfe9bb83a3cc2e80f567d8805e11b46f4a943f5515"}, + {file = "PyYAML-6.0.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:326c013efe8048858a6d312ddd31d56e468118ad4cdeda36c719bf5bb6192290"}, {file = "PyYAML-6.0.1-cp310-cp310-win32.whl", hash = "sha256:bd4af7373a854424dabd882decdc5579653d7868b8fb26dc7d0e99f823aa5924"}, {file = "PyYAML-6.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:fd1592b3fdf65fff2ad0004b5e363300ef59ced41c2e6b3a99d4089fa8c5435d"}, {file = "PyYAML-6.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6965a7bc3cf88e5a1c3bd2e0b5c22f8d677dc88a455344035f03399034eb3007"}, @@ -2106,8 +2143,16 @@ files = [ {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:42f8152b8dbc4fe7d96729ec2b99c7097d656dc1213a3229ca5383f973a5ed6d"}, {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:062582fca9fabdd2c8b54a3ef1c978d786e0f6b3a1510e0ac93ef59e0ddae2bc"}, {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d2b04aac4d386b172d5b9692e2d2da8de7bfb6c387fa4f801fbf6fb2e6ba4673"}, + {file = "PyYAML-6.0.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:e7d73685e87afe9f3b36c799222440d6cf362062f78be1013661b00c5c6f678b"}, {file = "PyYAML-6.0.1-cp311-cp311-win32.whl", hash = "sha256:1635fd110e8d85d55237ab316b5b011de701ea0f29d07611174a1b42f1444741"}, {file = "PyYAML-6.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:bf07ee2fef7014951eeb99f56f39c9bb4af143d8aa3c21b1677805985307da34"}, + {file = "PyYAML-6.0.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:855fb52b0dc35af121542a76b9a84f8d1cd886ea97c84703eaa6d88e37a2ad28"}, + {file = "PyYAML-6.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40df9b996c2b73138957fe23a16a4f0ba614f4c0efce1e9406a184b6d07fa3a9"}, + {file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a08c6f0fe150303c1c6b71ebcd7213c2858041a7e01975da3a99aed1e7a378ef"}, + {file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c22bec3fbe2524cde73d7ada88f6566758a8f7227bfbf93a408a9d86bcc12a0"}, + {file = "PyYAML-6.0.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8d4e9c88387b0f5c7d5f281e55304de64cf7f9c0021a3525bd3b1c542da3b0e4"}, + {file = "PyYAML-6.0.1-cp312-cp312-win32.whl", hash = "sha256:d483d2cdf104e7c9fa60c544d92981f12ad66a457afae824d146093b8c294c54"}, + {file = "PyYAML-6.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:0d3304d8c0adc42be59c5f8a4d9e3d7379e6955ad754aa9d6ab7a398b59dd1df"}, {file = "PyYAML-6.0.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:50550eb667afee136e9a77d6dc71ae76a44df8b3e51e41b77f6de2932bfe0f47"}, {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1fe35611261b29bd1de0070f0b2f47cb6ff71fa6595c077e42bd0c419fa27b98"}, {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:704219a11b772aea0d8ecd7058d0082713c3562b4e271b849ad7dc4a5c90c13c"}, @@ -2124,6 +2169,7 @@ files = [ {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a0cd17c15d3bb3fa06978b4e8958dcdc6e0174ccea823003a106c7d4d7899ac5"}, {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:28c119d996beec18c05208a8bd78cbe4007878c6dd15091efb73a30e90539696"}, {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7e07cbde391ba96ab58e532ff4803f79c4129397514e1413a7dc761ccd755735"}, + {file = "PyYAML-6.0.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:49a183be227561de579b4a36efbb21b3eab9651dd81b1858589f796549873dd6"}, {file = "PyYAML-6.0.1-cp38-cp38-win32.whl", hash = "sha256:184c5108a2aca3c5b3d3bf9395d50893a7ab82a38004c8f61c258d4428e80206"}, {file = "PyYAML-6.0.1-cp38-cp38-win_amd64.whl", hash = "sha256:1e2722cc9fbb45d9b87631ac70924c11d3a401b2d7f410cc0e3bbf249f2dca62"}, {file = "PyYAML-6.0.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:9eb6caa9a297fc2c2fb8862bc5370d0303ddba53ba97e71f08023b6cd73d16a8"}, @@ -2131,6 +2177,7 @@ files = [ {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5773183b6446b2c99bb77e77595dd486303b4faab2b086e7b17bc6bef28865f6"}, {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b786eecbdf8499b9ca1d697215862083bd6d2a99965554781d0d8d1ad31e13a0"}, {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc1bf2925a1ecd43da378f4db9e4f799775d6367bdb94671027b73b393a7c42c"}, + {file = "PyYAML-6.0.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:04ac92ad1925b2cff1db0cfebffb6ffc43457495c9b3c39d3fcae417d7125dc5"}, {file = "PyYAML-6.0.1-cp39-cp39-win32.whl", hash = "sha256:faca3bdcf85b2fc05d06ff3fbc1f83e1391b3e724afa3feba7d13eeab355484c"}, {file = "PyYAML-6.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:510c9deebc5c0225e8c96813043e62b680ba2f9c50a08d3724c7f28a747d1486"}, {file = "PyYAML-6.0.1.tar.gz", hash = "sha256:bfdf460b1736c775f2ba9f6a92bca30bc2095067b8a9d77876d1fad6cc3b4a43"}, @@ -2371,6 +2418,20 @@ files = [ {file = "ruff-0.5.5.tar.gz", hash = "sha256:cc5516bdb4858d972fbc31d246bdb390eab8df1a26e2353be2dbc0c2d7f5421a"}, ] +[[package]] +name = "scim2-models" +version = "0.1.14" +description = "SCIM2 models serialization and validation with pydantic" +optional = true +python-versions = "<4.0,>=3.9" +files = [ + {file = "scim2_models-0.1.14-py3-none-any.whl", hash = "sha256:50eb246bf2e391d9f96c2ed254e4753cf8daa143fd9b2733edef8c4071222065"}, + {file = "scim2_models-0.1.14.tar.gz", hash = "sha256:fe14a831ac99e1e77c06432dbe96b97ec13ad7b08e698c6b0575ebb5c901538e"}, +] + +[package.dependencies] +pydantic = {version = ">=2.7.0,<3.0.0", extras = ["email"]} + [[package]] name = "secretstorage" version = "3.3.3" @@ -3163,7 +3224,7 @@ docs = ["Sphinx", "repoze.sphinx.autointerface"] test = ["zope.i18nmessageid", "zope.testing", "zope.testrunner"] [extras] -all = ["Pympler", "authlib", "hiredis", "jaeger-client", "lxml", "matrix-synapse-ldap3", "opentracing", "psycopg2", "psycopg2cffi", "psycopg2cffi-compat", "pyicu", "pysaml2", "sentry-sdk", "txredisapi"] +all = ["Pympler", "authlib", "hiredis", "jaeger-client", "lxml", "matrix-synapse-ldap3", "opentracing", "psycopg2", "psycopg2cffi", "psycopg2cffi-compat", "pyicu", "pysaml2", "scim2-models", "sentry-sdk", "txredisapi"] cache-memory = ["Pympler"] jwt = ["authlib"] matrix-synapse-ldap3 = ["matrix-synapse-ldap3"] @@ -3172,6 +3233,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"] @@ -3181,4 +3243,4 @@ user-search = ["pyicu"] [metadata] lock-version = "2.0" python-versions = "^3.8.0" -content-hash = "c165cdc1f6612c9f1b5bfd8063c23e2d595d717dd8ac1a468519e902be2cdf93" +content-hash = "90af013c3cb934652f052706b23ad216de9456fddc652cffd5bd7f8ceaf97d6d" diff --git a/pyproject.toml b/pyproject.toml index c29d1534fb5..85e86dba99b 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.1.13", 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`. @@ -298,6 +300,8 @@ all = [ "pysaml2", # oidc and jwt "authlib", + # scim + "scim2-models", # url-preview "lxml", # sentry @@ -364,7 +368,6 @@ towncrier = ">=18.6.0rc1" # Used for checking the Poetry lockfile tomli = ">=1.2.3" - [build-system] # The upper bounds here are defensive, intended to prevent situations like # https://github.com/matrix-org/synapse/issues/13849 and diff --git a/synapse/app/homeserver.py b/synapse/app/homeserver.py index 2a824e8457f..61640d813c9 100644 --- a/synapse/app/homeserver.py +++ b/synapse/app/homeserver.py @@ -65,6 +65,7 @@ from synapse.rest.admin import AdminRestResource from synapse.rest.health import HealthResource from synapse.rest.key.v2 import KeyResource +from synapse.rest.scim import SCIMResource from synapse.rest.synapse.client import build_synapse_client_resource_tree from synapse.rest.well_known import well_known_resource from synapse.server import HomeServer @@ -185,6 +186,7 @@ def _configure_named_resource( CLIENT_API_PREFIX: client_resource, "/.well-known": well_known_resource(self), "/_synapse/admin": AdminRestResource(self), + "/_matrix/client/unstable/coop.yaal/scim/": SCIMResource(self), **build_synapse_client_resource_tree(self), } ) diff --git a/synapse/rest/__init__.py b/synapse/rest/__init__.py index c5cdc369557..1f31d51e277 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, @@ -120,6 +120,7 @@ login_token_request.register_servlets, rendezvous.register_servlets, auth_issuer.register_servlets, + scim.register_servlets, ) SERVLET_GROUPS: Dict[str, Iterable[RegisterServletsFunc]] = { diff --git a/synapse/rest/scim.py b/synapse/rest/scim.py new file mode 100644 index 00000000000..ddc9bf18d6f --- /dev/null +++ b/synapse/rest/scim.py @@ -0,0 +1,531 @@ +"""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, Tuple + +from scim2_models import ( + AuthenticationScheme, + Bulk, + ChangePassword, + Context, + Email, + Error, + ETag, + Filter, + ListResponse, + Meta, + Patch, + PhoneNumber, + Photo, + ResourceType, + Schema, + SearchRequest, + ServiceProviderConfig, + Sort, + User, +) + +from synapse.api.errors import SynapseError +from synapse.http.server import HttpServer, JsonResource +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.types import JsonDict, UserID + +if TYPE_CHECKING: + from synapse.server import HomeServer + +SCIM_PREFIX = "_matrix/client/unstable/coop.yaal/scim" + +logger = logging.getLogger(__name__) + + +class SCIMResource(JsonResource): + """The REST resource which gets mounted at + /_matrix/client/unstable/coop.yaal/scim""" + + def __init__(self, hs: "HomeServer"): + JsonResource.__init__(self, hs, canonical_json=False) + register_servlets(hs, self) + + +def register_servlets(hs: "HomeServer", http_server: HttpServer) -> None: + 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) + + +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 + + def make_error_response(self, status, message): + return ( + status, + Error( + status=status.value if isinstance(status, HTTPStatus) else status, + detail=message, + ).model_dump(), + ) + + def parse_search_request(self, request): + return SearchRequest( + attributes=parse_strings_from_args(request.args, "attributes"), + excluded_attributes=parse_strings_from_args( + request.args, "excludedAttributes" + ), + start_index=parse_integer(request, "startIndex", default=1, negative=True), + count=parse_integer( + request, "count", default=self.default_nb_items_per_page, negative=True + ), + ) + + async def get_scim_user(self, user_id: str): + 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) + scim_user = User( + meta=Meta( + resource_type="User", + created=creation_datetime, + last_modified=creation_datetime, + location=f"{self.config.server.public_baseurl}{SCIM_PREFIX}/Users/{user_id}", + ), + id=user_id, + external_id=user_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" + ], + phone_numbers=[ + PhoneNumber(value=threepid.address) + for threepid in threepids + if threepid.medium == "msisdn" + ], + ) + + if profile.avatar_url: + scim_user.photos = [ + Photo( + type=Photo.Type.photo, + primary=True, + value=profile.avatar_url, + ) + ] + + return scim_user + + +class UserServlet(SCIMServlet): + PATTERNS = [re.compile(f"^/{SCIM_PREFIX}/Users/(?P[^/]*)")] + + async def on_GET( + self, request: SynapseRequest, user_id: str + ) -> Tuple[int, JsonDict]: + 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 HTTPStatus.OK, payload + except SynapseError as exc: + return self.make_error_response(exc.code, exc.msg) + + async def on_DELETE( + self, request: SynapseRequest, user_id: str + ) -> Tuple[int, JsonDict]: + 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() + is_admin = await self.auth.is_server_admin(requester) + try: + await deactivate_account_handler.deactivate_account( + user_id, erase_data=True, requester=requester, by_admin=is_admin + ) + except SynapseError as exc: + return self.make_error_response(exc.code, exc.msg) + + return HTTPStatus.NO_CONTENT, "" + + async def on_PUT( + self, request: SynapseRequest, user_id: str + ) -> Tuple[int, JsonDict]: + requester = await self.auth.get_user_by_req(request) + await assert_user_is_admin(self.auth, requester) + + body = parse_json_object_from_request(request) + try: + user_id_obj = UserID.from_string(user_id) + + threepids = await self.store.user_get_threepids(user_id) + + default_display_name = body.get("displayName", "") + await self.profile_handler.set_displayname( + user_id_obj, requester, default_display_name, True + ) + + avatar_url = body["photos"][0]["value"] if body.get("photos") else "" + await self.profile_handler.set_avatar_url( + user_id_obj, requester, avatar_url, True + ) + + if threepids is not None: + new_threepids = { + ("email", email["value"]) for email in body["emails"] + } | { + ("msisdn", phone_number["value"]) + for phone_number in body["phoneNumbers"] + } + # 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 + ) + + user = await self.get_scim_user(user_id) + payload = user.model_dump(scim_ctx=Context.RESOURCE_REPLACEMENT_RESPONSE) + return HTTPStatus.OK, payload + + except SynapseError as exc: + return self.make_error_response(exc.code, exc.msg) + + +class UserListServlet(SCIMServlet): + PATTERNS = [re.compile(f"^/{SCIM_PREFIX}/Users/?$")] + + async def on_GET(self, request: SynapseRequest) -> Tuple[int, JsonDict]: + 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 - 1, + limit=req.count, + ) + users = [await self.get_scim_user(item.name) for item in items] + list_response = ListResponse.of(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 HTTPStatus.OK, payload + + except SynapseError as exc: + return self.make_error_response(exc.code, exc.msg) + + async def on_POST(self, request: SynapseRequest) -> Tuple[int, JsonDict]: + 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) + + from synapse.rest.client.register import RegisterRestServlet + + register = RegisterRestServlet(self.hs) + + registration_arguments = { + "by_admin": True, + "approved": True, + "localpart": body["userName"], + } + + if password := body.get("password"): + registration_arguments["password_hash"] = await self.auth_handler.hash( + password + ) + + if display_name := body.get("displayName"): + registration_arguments["default_display_name"] = display_name + + user_id = await register.registration_handler.register_user( + **registration_arguments + ) + + await register._create_registration_details( + user_id, + body, + should_issue_refresh_token=True, + ) + + now_ts = self.hs.get_clock().time_msec() + for email in body.get("emails", []): + await self.store.user_add_threepid( + user_id, "email", email["value"], now_ts, now_ts + ) + + for phone_number in body.get("phoneNumbers", []): + await self.store.user_add_threepid( + user_id, "msisdn", phone_number["value"], now_ts, now_ts + ) + + avatar_url = body["photos"][0]["value"] if body.get("photos") else None + if avatar_url: + await self.profile_handler.set_avatar_url( + UserID.from_string(user_id), requester, avatar_url, True + ) + + user = await self.get_scim_user(user_id) + payload = user.model_dump(scim_ctx=Context.RESOURCE_CREATION_RESPONSE) + return HTTPStatus.CREATED, payload + + except SynapseError as exc: + return self.make_error_response(exc.code, exc.msg) + + +class ServiceProviderConfigServlet(SCIMServlet): + PATTERNS = [re.compile(f"^/{SCIM_PREFIX}/ServiceProviderConfig$")] + + async def on_GET(self, request: SynapseRequest) -> Tuple[int, JsonDict]: + spc = ServiceProviderConfig( + meta=Meta( + resource_type="ServiceProviderConfig", + location=( + self.config.server.public_baseurl + + SCIM_PREFIX + + "/ServiceProviderConfig" + ), + ), + documentation_uri="https://element-hq.github.io/synapse/latest/admin_api/scim_api.html", + patch=Patch(supported=False), + bulk=Bulk(supported=False, maxOperations=0, maxPayloadSize=0), + changePassword=ChangePassword(supported=True), + filter=Filter(supported=False, maxResults=0), + sort=Sort(supported=False), + etag=ETag(supported=False), + authenticationSchemes=[ + AuthenticationScheme( + name="OAuth Bearer Token", + description="Authentication scheme using the OAuth Bearer Token Standard", + specUri="http://www.rfc-editor.org/info/rfc6750", + documentationUri="https://element-hq.github.io/synapse/latest/openid.html", + type="oauthbearertoken", + primary=True, + ), + AuthenticationScheme( + name="HTTP Basic", + description="Authentication scheme using the HTTP Basic Standard", + specUri="http://www.rfc-editor.org/info/rfc2617", + documentationUri="https://element-hq.github.io/synapse/latest/modules/password_auth_provider_callbacks.html", + type="httpbasic", + ), + ], + ) + return HTTPStatus.OK, spc.model_dump(scim_ctx=Context.RESOURCE_QUERY_RESPONSE) + + +class BaseSchemaServlet(SCIMServlet): + schemas: Dict[str, Schema] + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + 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 + + "/Schemas/" + + schema_id + ), + ) + + +class SchemaListServlet(BaseSchemaServlet): + PATTERNS = [re.compile(f"^/{SCIM_PREFIX}/Schemas$")] + + async def on_GET(self, request: SynapseRequest) -> Tuple[int, JsonDict]: + """Return the list of schemas provided by the synapse SCIM implementation.""" + + req = self.parse_search_request(request) + stop_index = req.start_index + req.count if req.count else None + resources = list(self.schemas.values()) + response = ListResponse.of(Schema)( + total_results=len(resources), + items_per_page=req.count or len(resources), + start_index=req.start_index, + resources=resources[req.start_index - 1 : stop_index], + ) + return 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]: + """Given an id, return a schema provided by the synapse SCIM implementation.""" + + try: + return HTTPStatus.OK, self.schemas[schema_id].model_dump( + scim_ctx=Context.RESOURCE_QUERY_RESPONSE + ) + except KeyError: + return self.make_error_response(HTTPStatus.NOT_FOUND, "Object not found") + + +class BaseResourceTypeServlet(SCIMServlet): + resource_type: ResourceType + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + 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 + + "/ResourceTypes/User" + ), + ), + ) + + +class ResourceTypeListServlet(BaseResourceTypeServlet): + PATTERNS = [re.compile(f"^/{SCIM_PREFIX}/ResourceTypes$")] + + async def on_GET(self, request: SynapseRequest) -> Tuple[int, JsonDict]: + req = self.parse_search_request(request) + stop_index = req.start_index + req.count if req.count else None + resources = [ + self.resource_type.model_dump(scim_ctx=Context.RESOURCE_QUERY_RESPONSE) + ] + response = ListResponse.of(ResourceType)( + total_results=len(resources), + items_per_page=req.count or len(resources), + start_index=req.start_index, + resources=resources[req.start_index - 1 : stop_index], + ) + return 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]: + resource_types = { + "User": self.resource_type.model_dump( + scim_ctx=Context.RESOURCE_QUERY_RESPONSE + ), + } + + try: + return HTTPStatus.OK, resource_types[resource_type] + except KeyError: + return self.make_error_response(HTTPStatus.NOT_FOUND, "Object not found") diff --git a/tests/rest/test_scim.py b/tests/rest/test_scim.py new file mode 100644 index 00000000000..8d67f25ceaf --- /dev/null +++ b/tests/rest/test_scim.py @@ -0,0 +1,613 @@ +from unittest import mock + +from twisted.test.proto_helpers import MemoryReactor + +import synapse.rest.admin +import synapse.rest.scim +from synapse.rest.client import login +from synapse.server import HomeServer +from synapse.types import JsonDict, UserID +from synapse.util import Clock + +from tests import unittest + + +class UserProvisioningTestCase(unittest.HomeserverTestCase): + servlets = [ + synapse.rest.admin.register_servlets_for_client_rest_resource, + synapse.rest.scim.register_servlets, + login.register_servlets, + ] + url = "/_matrix/client/unstable/coop.yaal/scim" + + 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), + "https://mydomain.tld/photo.webp", + ) + ) + + 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/_matrix/client/unstable/coop.yaal/scim/Users/@user:test", + }, + "id": "@user:test", + "userName": "user", + "externalId": "@user:test", + "phoneNumbers": [{"value": "+1-12345678"}], + "emails": [{"value": "user@mydomain.tld"}], + "active": True, + "displayName": "user display name", + "photos": [ + { + "type": "photo", + "primary": True, + "value": "https://mydomain.tld/photo.webp", + } + ], + }, + channel.json_body, + ) + + 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/_matrix/client/unstable/coop.yaal/scim/Users/@user:test", + }, + "id": "@user:test", + "externalId": "@user:test", + "phoneNumbers": [{"value": "+1-12345678"}], + "emails": [{"value": "user@mydomain.tld"}], + "active": True, + "displayName": "user display name", + "photos": [ + { + "type": "photo", + "primary": True, + "value": "https://mydomain.tld/photo.webp", + } + ], + }, + 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/_matrix/client/unstable/coop.yaal/scim/Users/@user:test", + }, + "id": "@user:test", + "userName": "user", + "externalId": "@user:test", + "phoneNumbers": [{"value": "+1-12345678"}], + "emails": [{"value": "user@mydomain.tld"}], + "active": True, + "displayName": "user display name", + "photos": [ + { + "type": "photo", + "primary": True, + "value": "https://mydomain.tld/photo.webp", + } + ], + } + 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": "bjensen@test", + "phoneNumbers": [{"value": "+1-12345678"}], + "emails": [{"value": "bjensen@mydomain.tld"}], + "photos": [ + { + "type": "photo", + "primary": True, + "value": "https://mydomain.tld/photo.webp", + } + ], + "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/_matrix/client/unstable/coop.yaal/scim/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", + } + self.assertEqual(expected, channel.json_body) + + 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) + + 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/_matrix/client/unstable/coop.yaal/scim/Users/@user:test", + }, + "id": "@user:test", + "userName": "user", + "externalId": "@user:test", + "phoneNumbers": [{"value": "+1-12345678"}], + "emails": [{"value": "user@mydomain.tld"}], + "photos": [ + { + "type": "photo", + "primary": True, + "value": "https://mydomain.tld/photo.webp", + } + ], + "active": True, + "displayName": "user display name", + }, + channel.json_body, + ) + + request_data: JsonDict = { + "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"], + "phoneNumbers": [{"value": "+1-11112222"}], + "emails": [{"value": "newmail@mydomain.tld"}], + "displayName": "new display name", + "photos": [ + { + "type": "photo", + "primary": True, + "value": "https://mydomain.tld/photo.webp", + } + ], + } + + 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/_matrix/client/unstable/coop.yaal/scim/Users/@user:test", + }, + "id": "@user:test", + "externalId": "@user:test", + "phoneNumbers": [{"value": "+1-11112222"}], + "userName": "user", + "emails": [{"value": "newmail@mydomain.tld"}], + "active": True, + "displayName": "new display name", + "photos": [ + { + "type": "photo", + "primary": True, + "value": "https://mydomain.tld/photo.webp", + } + ], + } + self.assertEqual(expected, channel.json_body) + + 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"}], + "displayName": "new display name", + "photos": [ + { + "type": "photo", + "primary": True, + "value": "https://mydomain.tld/photo.webp", + } + ], + } + + 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"], + ) + + +class SCIMMetadataTestCase(unittest.HomeserverTestCase): + servlets = [ + synapse.rest.admin.register_servlets_for_client_rest_resource, + synapse.rest.scim.register_servlets, + login.register_servlets, + ] + url = "/_matrix/client/unstable/coop.yaal/scim" + + 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"], + )