mirror of
https://github.com/element-hq/synapse
synced 2024-06-30 17:13:29 +00:00
Compare commits
9 commits
0d456d2e32
...
f987fb5f10
Author | SHA1 | Date | |
---|---|---|---|
f987fb5f10 | |||
2c36a679ae | |||
c12ee0d5ba | |||
8aaff851b1 | |||
8c58eb7f17 | |||
ebdce69f6a | |||
c6eb99c878 | |||
5db3eec5bc | |||
81d751b41c |
1
changelog.d/17144.feature
Normal file
1
changelog.d/17144.feature
Normal file
|
@ -0,0 +1 @@
|
||||||
|
Add support for MSC4098 (SCIM provisioning protocol).
|
1
changelog.d/17277.feature
Normal file
1
changelog.d/17277.feature
Normal file
|
@ -0,0 +1 @@
|
||||||
|
Add `is_dm` filtering to experimental [MSC3575](https://github.com/matrix-org/matrix-spec-proposals/pull/3575) Sliding Sync `/sync` endpoint.
|
1
changelog.d/17282.feature
Normal file
1
changelog.d/17282.feature
Normal file
|
@ -0,0 +1 @@
|
||||||
|
Include user membership in events served to clients, per MSC4115.
|
1
changelog.d/17295.bugfix
Normal file
1
changelog.d/17295.bugfix
Normal file
|
@ -0,0 +1 @@
|
||||||
|
Fix edge case in `/sync` returning the wrong the state when using sharded event persisters.
|
1
changelog.d/17296.feature
Normal file
1
changelog.d/17296.feature
Normal file
|
@ -0,0 +1 @@
|
||||||
|
Add support for the unstable [MSC4151](https://github.com/matrix-org/matrix-spec-proposals/pull/4151) report room API.
|
1
changelog.d/17297.misc
Normal file
1
changelog.d/17297.misc
Normal file
|
@ -0,0 +1 @@
|
||||||
|
Bump `mypy` from 1.8.0 to 1.9.0.
|
1
changelog.d/17300.misc
Normal file
1
changelog.d/17300.misc
Normal file
|
@ -0,0 +1 @@
|
||||||
|
Expose the worker instance that persisted the event on `event.internal_metadata.instance_name`.
|
1
changelog.d/17301.bugfix
Normal file
1
changelog.d/17301.bugfix
Normal file
|
@ -0,0 +1 @@
|
||||||
|
Add initial implementation of an experimental [MSC3575](https://github.com/matrix-org/matrix-spec-proposals/pull/3575) Sliding Sync `/sync` endpoint.
|
|
@ -105,8 +105,6 @@ experimental_features:
|
||||||
# Expose a room summary for public rooms
|
# Expose a room summary for public rooms
|
||||||
msc3266_enabled: true
|
msc3266_enabled: true
|
||||||
|
|
||||||
msc4115_membership_on_events: true
|
|
||||||
|
|
||||||
server_notices:
|
server_notices:
|
||||||
system_mxid_localpart: _server
|
system_mxid_localpart: _server
|
||||||
system_mxid_display_name: "Server Alert"
|
system_mxid_display_name: "Server Alert"
|
||||||
|
|
|
@ -70,6 +70,7 @@
|
||||||
- [Users](admin_api/user_admin_api.md)
|
- [Users](admin_api/user_admin_api.md)
|
||||||
- [Server Version](admin_api/version_api.md)
|
- [Server Version](admin_api/version_api.md)
|
||||||
- [Federation](usage/administration/admin_api/federation.md)
|
- [Federation](usage/administration/admin_api/federation.md)
|
||||||
|
- [SCIM provisioning](usage/administration/admin_api/scim_api.md)
|
||||||
- [Manhole](manhole.md)
|
- [Manhole](manhole.md)
|
||||||
- [Monitoring](metrics-howto.md)
|
- [Monitoring](metrics-howto.md)
|
||||||
- [Reporting Homeserver Usage Statistics](usage/administration/monitoring/reporting_homeserver_usage_statistics.md)
|
- [Reporting Homeserver Usage Statistics](usage/administration/monitoring/reporting_homeserver_usage_statistics.md)
|
||||||
|
|
1
docs/admin_api/scim_api.md
Normal file
1
docs/admin_api/scim_api.md
Normal file
|
@ -0,0 +1 @@
|
||||||
|
# SCIM API
|
56
poetry.lock
generated
56
poetry.lock
generated
|
@ -1384,38 +1384,38 @@ files = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mypy"
|
name = "mypy"
|
||||||
version = "1.8.0"
|
version = "1.9.0"
|
||||||
description = "Optional static typing for Python"
|
description = "Optional static typing for Python"
|
||||||
optional = false
|
optional = false
|
||||||
python-versions = ">=3.8"
|
python-versions = ">=3.8"
|
||||||
files = [
|
files = [
|
||||||
{file = "mypy-1.8.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:485a8942f671120f76afffff70f259e1cd0f0cfe08f81c05d8816d958d4577d3"},
|
{file = "mypy-1.9.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:f8a67616990062232ee4c3952f41c779afac41405806042a8126fe96e098419f"},
|
||||||
{file = "mypy-1.8.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:df9824ac11deaf007443e7ed2a4a26bebff98d2bc43c6da21b2b64185da011c4"},
|
{file = "mypy-1.9.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:d357423fa57a489e8c47b7c85dfb96698caba13d66e086b412298a1a0ea3b0ed"},
|
||||||
{file = "mypy-1.8.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2afecd6354bbfb6e0160f4e4ad9ba6e4e003b767dd80d85516e71f2e955ab50d"},
|
{file = "mypy-1.9.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:49c87c15aed320de9b438ae7b00c1ac91cd393c1b854c2ce538e2a72d55df150"},
|
||||||
{file = "mypy-1.8.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:8963b83d53ee733a6e4196954502b33567ad07dfd74851f32be18eb932fb1cb9"},
|
{file = "mypy-1.9.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:48533cdd345c3c2e5ef48ba3b0d3880b257b423e7995dada04248725c6f77374"},
|
||||||
{file = "mypy-1.8.0-cp310-cp310-win_amd64.whl", hash = "sha256:e46f44b54ebddbeedbd3d5b289a893219065ef805d95094d16a0af6630f5d410"},
|
{file = "mypy-1.9.0-cp310-cp310-win_amd64.whl", hash = "sha256:4d3dbd346cfec7cb98e6cbb6e0f3c23618af826316188d587d1c1bc34f0ede03"},
|
||||||
{file = "mypy-1.8.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:855fe27b80375e5c5878492f0729540db47b186509c98dae341254c8f45f42ae"},
|
{file = "mypy-1.9.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:653265f9a2784db65bfca694d1edd23093ce49740b2244cde583aeb134c008f3"},
|
||||||
{file = "mypy-1.8.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4c886c6cce2d070bd7df4ec4a05a13ee20c0aa60cb587e8d1265b6c03cf91da3"},
|
{file = "mypy-1.9.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:3a3c007ff3ee90f69cf0a15cbcdf0995749569b86b6d2f327af01fd1b8aee9dc"},
|
||||||
{file = "mypy-1.8.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d19c413b3c07cbecf1f991e2221746b0d2a9410b59cb3f4fb9557f0365a1a817"},
|
{file = "mypy-1.9.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2418488264eb41f69cc64a69a745fad4a8f86649af4b1041a4c64ee61fc61129"},
|
||||||
{file = "mypy-1.8.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:9261ed810972061388918c83c3f5cd46079d875026ba97380f3e3978a72f503d"},
|
{file = "mypy-1.9.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:68edad3dc7d70f2f17ae4c6c1b9471a56138ca22722487eebacfd1eb5321d612"},
|
||||||
{file = "mypy-1.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:51720c776d148bad2372ca21ca29256ed483aa9a4cdefefcef49006dff2a6835"},
|
{file = "mypy-1.9.0-cp311-cp311-win_amd64.whl", hash = "sha256:85ca5fcc24f0b4aeedc1d02f93707bccc04733f21d41c88334c5482219b1ccb3"},
|
||||||
{file = "mypy-1.8.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:52825b01f5c4c1c4eb0db253ec09c7aa17e1a7304d247c48b6f3599ef40db8bd"},
|
{file = "mypy-1.9.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:aceb1db093b04db5cd390821464504111b8ec3e351eb85afd1433490163d60cd"},
|
||||||
{file = "mypy-1.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f5ac9a4eeb1ec0f1ccdc6f326bcdb464de5f80eb07fb38b5ddd7b0de6bc61e55"},
|
{file = "mypy-1.9.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0235391f1c6f6ce487b23b9dbd1327b4ec33bb93934aa986efe8a9563d9349e6"},
|
||||||
{file = "mypy-1.8.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:afe3fe972c645b4632c563d3f3eff1cdca2fa058f730df2b93a35e3b0c538218"},
|
{file = "mypy-1.9.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d4d5ddc13421ba3e2e082a6c2d74c2ddb3979c39b582dacd53dd5d9431237185"},
|
||||||
{file = "mypy-1.8.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:42c6680d256ab35637ef88891c6bd02514ccb7e1122133ac96055ff458f93fc3"},
|
{file = "mypy-1.9.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:190da1ee69b427d7efa8aa0d5e5ccd67a4fb04038c380237a0d96829cb157913"},
|
||||||
{file = "mypy-1.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:720a5ca70e136b675af3af63db533c1c8c9181314d207568bbe79051f122669e"},
|
{file = "mypy-1.9.0-cp312-cp312-win_amd64.whl", hash = "sha256:fe28657de3bfec596bbeef01cb219833ad9d38dd5393fc649f4b366840baefe6"},
|
||||||
{file = "mypy-1.8.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:028cf9f2cae89e202d7b6593cd98db6759379f17a319b5faf4f9978d7084cdc6"},
|
{file = "mypy-1.9.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:e54396d70be04b34f31d2edf3362c1edd023246c82f1730bbf8768c28db5361b"},
|
||||||
{file = "mypy-1.8.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:4e6d97288757e1ddba10dd9549ac27982e3e74a49d8d0179fc14d4365c7add66"},
|
{file = "mypy-1.9.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:5e6061f44f2313b94f920e91b204ec600982961e07a17e0f6cd83371cb23f5c2"},
|
||||||
{file = "mypy-1.8.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7f1478736fcebb90f97e40aff11a5f253af890c845ee0c850fe80aa060a267c6"},
|
{file = "mypy-1.9.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:81a10926e5473c5fc3da8abb04119a1f5811a236dc3a38d92015cb1e6ba4cb9e"},
|
||||||
{file = "mypy-1.8.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:42419861b43e6962a649068a61f4a4839205a3ef525b858377a960b9e2de6e0d"},
|
{file = "mypy-1.9.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:b685154e22e4e9199fc95f298661deea28aaede5ae16ccc8cbb1045e716b3e04"},
|
||||||
{file = "mypy-1.8.0-cp38-cp38-win_amd64.whl", hash = "sha256:2b5b6c721bd4aabaadead3a5e6fa85c11c6c795e0c81a7215776ef8afc66de02"},
|
{file = "mypy-1.9.0-cp38-cp38-win_amd64.whl", hash = "sha256:5d741d3fc7c4da608764073089e5f58ef6352bedc223ff58f2f038c2c4698a89"},
|
||||||
{file = "mypy-1.8.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:5c1538c38584029352878a0466f03a8ee7547d7bd9f641f57a0f3017a7c905b8"},
|
{file = "mypy-1.9.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:587ce887f75dd9700252a3abbc9c97bbe165a4a630597845c61279cf32dfbf02"},
|
||||||
{file = "mypy-1.8.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:4ef4be7baf08a203170f29e89d79064463b7fc7a0908b9d0d5114e8009c3a259"},
|
{file = "mypy-1.9.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:f88566144752999351725ac623471661c9d1cd8caa0134ff98cceeea181789f4"},
|
||||||
{file = "mypy-1.8.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7178def594014aa6c35a8ff411cf37d682f428b3b5617ca79029d8ae72f5402b"},
|
{file = "mypy-1.9.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:61758fabd58ce4b0720ae1e2fea5cfd4431591d6d590b197775329264f86311d"},
|
||||||
{file = "mypy-1.8.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:ab3c84fa13c04aeeeabb2a7f67a25ef5d77ac9d6486ff33ded762ef353aa5592"},
|
{file = "mypy-1.9.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:e49499be624dead83927e70c756970a0bc8240e9f769389cdf5714b0784ca6bf"},
|
||||||
{file = "mypy-1.8.0-cp39-cp39-win_amd64.whl", hash = "sha256:99b00bc72855812a60d253420d8a2eae839b0afa4938f09f4d2aa9bb4654263a"},
|
{file = "mypy-1.9.0-cp39-cp39-win_amd64.whl", hash = "sha256:571741dc4194b4f82d344b15e8837e8c5fcc462d66d076748142327626a1b6e9"},
|
||||||
{file = "mypy-1.8.0-py3-none-any.whl", hash = "sha256:538fd81bb5e430cc1381a443971c0475582ff9f434c16cd46d2c66763ce85d9d"},
|
{file = "mypy-1.9.0-py3-none-any.whl", hash = "sha256:a260627a570559181a9ea5de61ac6297aa5af202f06fd7ab093ce74e7181e43e"},
|
||||||
{file = "mypy-1.8.0.tar.gz", hash = "sha256:6ff8b244d7085a0b425b56d327b480c3b29cafbd2eff27316a004f9a7391ae07"},
|
{file = "mypy-1.9.0.tar.gz", hash = "sha256:3cc5da0127e6a478cddd906068496a97a7618a21ce9b54bde5bf7e539c7af974"},
|
||||||
]
|
]
|
||||||
|
|
||||||
[package.dependencies]
|
[package.dependencies]
|
||||||
|
|
|
@ -204,6 +204,8 @@ pub struct EventInternalMetadata {
|
||||||
/// The stream ordering of this event. None, until it has been persisted.
|
/// The stream ordering of this event. None, until it has been persisted.
|
||||||
#[pyo3(get, set)]
|
#[pyo3(get, set)]
|
||||||
stream_ordering: Option<NonZeroI64>,
|
stream_ordering: Option<NonZeroI64>,
|
||||||
|
#[pyo3(get, set)]
|
||||||
|
instance_name: Option<String>,
|
||||||
|
|
||||||
/// whether this event is an outlier (ie, whether we have the state at that
|
/// whether this event is an outlier (ie, whether we have the state at that
|
||||||
/// point in the DAG)
|
/// point in the DAG)
|
||||||
|
@ -232,6 +234,7 @@ impl EventInternalMetadata {
|
||||||
Ok(EventInternalMetadata {
|
Ok(EventInternalMetadata {
|
||||||
data,
|
data,
|
||||||
stream_ordering: None,
|
stream_ordering: None,
|
||||||
|
instance_name: None,
|
||||||
outlier: false,
|
outlier: false,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
@ -223,7 +223,6 @@ test_packages=(
|
||||||
./tests/msc3930
|
./tests/msc3930
|
||||||
./tests/msc3902
|
./tests/msc3902
|
||||||
./tests/msc3967
|
./tests/msc3967
|
||||||
./tests/msc4115
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Enable dirty runs, so tests will reuse the same container where possible.
|
# Enable dirty runs, so tests will reuse the same container where possible.
|
||||||
|
|
|
@ -238,7 +238,7 @@ class EventUnsignedContentFields:
|
||||||
"""Fields found inside the 'unsigned' data on events"""
|
"""Fields found inside the 'unsigned' data on events"""
|
||||||
|
|
||||||
# Requesting user's membership, per MSC4115
|
# Requesting user's membership, per MSC4115
|
||||||
MSC4115_MEMBERSHIP: Final = "io.element.msc4115.membership"
|
MEMBERSHIP: Final = "membership"
|
||||||
|
|
||||||
|
|
||||||
class RoomTypes:
|
class RoomTypes:
|
||||||
|
|
|
@ -65,6 +65,7 @@ from synapse.rest import ClientRestResource
|
||||||
from synapse.rest.admin import AdminRestResource
|
from synapse.rest.admin import AdminRestResource
|
||||||
from synapse.rest.health import HealthResource
|
from synapse.rest.health import HealthResource
|
||||||
from synapse.rest.key.v2 import KeyResource
|
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.synapse.client import build_synapse_client_resource_tree
|
||||||
from synapse.rest.well_known import well_known_resource
|
from synapse.rest.well_known import well_known_resource
|
||||||
from synapse.server import HomeServer
|
from synapse.server import HomeServer
|
||||||
|
@ -179,6 +180,7 @@ class SynapseHomeServer(HomeServer):
|
||||||
CLIENT_API_PREFIX: client_resource,
|
CLIENT_API_PREFIX: client_resource,
|
||||||
"/.well-known": well_known_resource(self),
|
"/.well-known": well_known_resource(self),
|
||||||
"/_synapse/admin": AdminRestResource(self),
|
"/_synapse/admin": AdminRestResource(self),
|
||||||
|
"/_matrix/client/unstable/coop.yaal/scim/": SCIMResource(self),
|
||||||
**build_synapse_client_resource_tree(self),
|
**build_synapse_client_resource_tree(self),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
|
@ -436,10 +436,6 @@ class ExperimentalConfig(Config):
|
||||||
("experimental", "msc4108_delegation_endpoint"),
|
("experimental", "msc4108_delegation_endpoint"),
|
||||||
)
|
)
|
||||||
|
|
||||||
self.msc4115_membership_on_events = experimental.get(
|
|
||||||
"msc4115_membership_on_events", False
|
|
||||||
)
|
|
||||||
|
|
||||||
self.msc3916_authenticated_media_enabled = experimental.get(
|
self.msc3916_authenticated_media_enabled = experimental.get(
|
||||||
"msc3916_authenticated_media_enabled", False
|
"msc3916_authenticated_media_enabled", False
|
||||||
)
|
)
|
||||||
|
|
|
@ -90,6 +90,7 @@ def prune_event(event: EventBase) -> EventBase:
|
||||||
pruned_event.internal_metadata.stream_ordering = (
|
pruned_event.internal_metadata.stream_ordering = (
|
||||||
event.internal_metadata.stream_ordering
|
event.internal_metadata.stream_ordering
|
||||||
)
|
)
|
||||||
|
pruned_event.internal_metadata.instance_name = event.internal_metadata.instance_name
|
||||||
pruned_event.internal_metadata.outlier = event.internal_metadata.outlier
|
pruned_event.internal_metadata.outlier = event.internal_metadata.outlier
|
||||||
|
|
||||||
# Mark the event as redacted
|
# Mark the event as redacted
|
||||||
|
@ -116,6 +117,7 @@ def clone_event(event: EventBase) -> EventBase:
|
||||||
new_event.internal_metadata.stream_ordering = (
|
new_event.internal_metadata.stream_ordering = (
|
||||||
event.internal_metadata.stream_ordering
|
event.internal_metadata.stream_ordering
|
||||||
)
|
)
|
||||||
|
new_event.internal_metadata.instance_name = event.internal_metadata.instance_name
|
||||||
new_event.internal_metadata.outlier = event.internal_metadata.outlier
|
new_event.internal_metadata.outlier = event.internal_metadata.outlier
|
||||||
|
|
||||||
return new_event
|
return new_event
|
||||||
|
|
|
@ -42,7 +42,6 @@ class AdminHandler:
|
||||||
self._device_handler = hs.get_device_handler()
|
self._device_handler = hs.get_device_handler()
|
||||||
self._storage_controllers = hs.get_storage_controllers()
|
self._storage_controllers = hs.get_storage_controllers()
|
||||||
self._state_storage_controller = self._storage_controllers.state
|
self._state_storage_controller = self._storage_controllers.state
|
||||||
self._hs_config = hs.config
|
|
||||||
self._msc3866_enabled = hs.config.experimental.msc3866.enabled
|
self._msc3866_enabled = hs.config.experimental.msc3866.enabled
|
||||||
|
|
||||||
async def get_whois(self, user: UserID) -> JsonMapping:
|
async def get_whois(self, user: UserID) -> JsonMapping:
|
||||||
|
@ -215,7 +214,6 @@ class AdminHandler:
|
||||||
self._storage_controllers,
|
self._storage_controllers,
|
||||||
user_id,
|
user_id,
|
||||||
events,
|
events,
|
||||||
msc4115_membership_on_events=self._hs_config.experimental.msc4115_membership_on_events,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
writer.write_events(room_id, events)
|
writer.write_events(room_id, events)
|
||||||
|
|
|
@ -148,7 +148,6 @@ class EventHandler:
|
||||||
def __init__(self, hs: "HomeServer"):
|
def __init__(self, hs: "HomeServer"):
|
||||||
self.store = hs.get_datastores().main
|
self.store = hs.get_datastores().main
|
||||||
self._storage_controllers = hs.get_storage_controllers()
|
self._storage_controllers = hs.get_storage_controllers()
|
||||||
self._config = hs.config
|
|
||||||
|
|
||||||
async def get_event(
|
async def get_event(
|
||||||
self,
|
self,
|
||||||
|
@ -194,7 +193,6 @@ class EventHandler:
|
||||||
user.to_string(),
|
user.to_string(),
|
||||||
[event],
|
[event],
|
||||||
is_peeking=is_peeking,
|
is_peeking=is_peeking,
|
||||||
msc4115_membership_on_events=self._config.experimental.msc4115_membership_on_events,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if not filtered:
|
if not filtered:
|
||||||
|
|
|
@ -224,7 +224,6 @@ class InitialSyncHandler:
|
||||||
self._storage_controllers,
|
self._storage_controllers,
|
||||||
user_id,
|
user_id,
|
||||||
messages,
|
messages,
|
||||||
msc4115_membership_on_events=self.hs.config.experimental.msc4115_membership_on_events,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
start_token = now_token.copy_and_replace(StreamKeyType.ROOM, token)
|
start_token = now_token.copy_and_replace(StreamKeyType.ROOM, token)
|
||||||
|
@ -383,7 +382,6 @@ class InitialSyncHandler:
|
||||||
requester.user.to_string(),
|
requester.user.to_string(),
|
||||||
messages,
|
messages,
|
||||||
is_peeking=is_peeking,
|
is_peeking=is_peeking,
|
||||||
msc4115_membership_on_events=self.hs.config.experimental.msc4115_membership_on_events,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
start_token = StreamToken.START.copy_and_replace(StreamKeyType.ROOM, token)
|
start_token = StreamToken.START.copy_and_replace(StreamKeyType.ROOM, token)
|
||||||
|
@ -498,7 +496,6 @@ class InitialSyncHandler:
|
||||||
requester.user.to_string(),
|
requester.user.to_string(),
|
||||||
messages,
|
messages,
|
||||||
is_peeking=is_peeking,
|
is_peeking=is_peeking,
|
||||||
msc4115_membership_on_events=self.hs.config.experimental.msc4115_membership_on_events,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
start_token = now_token.copy_and_replace(StreamKeyType.ROOM, token)
|
start_token = now_token.copy_and_replace(StreamKeyType.ROOM, token)
|
||||||
|
|
|
@ -1551,6 +1551,7 @@ class EventCreationHandler:
|
||||||
# stream_ordering entry manually (as it was persisted on
|
# stream_ordering entry manually (as it was persisted on
|
||||||
# another worker).
|
# another worker).
|
||||||
event.internal_metadata.stream_ordering = stream_id
|
event.internal_metadata.stream_ordering = stream_id
|
||||||
|
event.internal_metadata.instance_name = writer_instance
|
||||||
|
|
||||||
return event
|
return event
|
||||||
|
|
||||||
|
|
|
@ -623,7 +623,6 @@ class PaginationHandler:
|
||||||
user_id,
|
user_id,
|
||||||
events,
|
events,
|
||||||
is_peeking=(member_event_id is None),
|
is_peeking=(member_event_id is None),
|
||||||
msc4115_membership_on_events=self.hs.config.experimental.msc4115_membership_on_events,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# if after the filter applied there are no more events
|
# if after the filter applied there are no more events
|
||||||
|
|
|
@ -95,7 +95,6 @@ class RelationsHandler:
|
||||||
self._event_handler = hs.get_event_handler()
|
self._event_handler = hs.get_event_handler()
|
||||||
self._event_serializer = hs.get_event_client_serializer()
|
self._event_serializer = hs.get_event_client_serializer()
|
||||||
self._event_creation_handler = hs.get_event_creation_handler()
|
self._event_creation_handler = hs.get_event_creation_handler()
|
||||||
self._config = hs.config
|
|
||||||
|
|
||||||
async def get_relations(
|
async def get_relations(
|
||||||
self,
|
self,
|
||||||
|
@ -164,7 +163,6 @@ class RelationsHandler:
|
||||||
user_id,
|
user_id,
|
||||||
events,
|
events,
|
||||||
is_peeking=(member_event_id is None),
|
is_peeking=(member_event_id is None),
|
||||||
msc4115_membership_on_events=self._config.experimental.msc4115_membership_on_events,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# The relations returned for the requested event do include their
|
# The relations returned for the requested event do include their
|
||||||
|
@ -610,7 +608,6 @@ class RelationsHandler:
|
||||||
user_id,
|
user_id,
|
||||||
events,
|
events,
|
||||||
is_peeking=(member_event_id is None),
|
is_peeking=(member_event_id is None),
|
||||||
msc4115_membership_on_events=self._config.experimental.msc4115_membership_on_events,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
aggregations = await self.get_bundled_aggregations(
|
aggregations = await self.get_bundled_aggregations(
|
||||||
|
|
|
@ -1476,7 +1476,6 @@ class RoomContextHandler:
|
||||||
user.to_string(),
|
user.to_string(),
|
||||||
events,
|
events,
|
||||||
is_peeking=is_peeking,
|
is_peeking=is_peeking,
|
||||||
msc4115_membership_on_events=self.hs.config.experimental.msc4115_membership_on_events,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
event = await self.store.get_event(
|
event = await self.store.get_event(
|
||||||
|
|
|
@ -483,7 +483,6 @@ class SearchHandler:
|
||||||
self._storage_controllers,
|
self._storage_controllers,
|
||||||
user.to_string(),
|
user.to_string(),
|
||||||
filtered_events,
|
filtered_events,
|
||||||
msc4115_membership_on_events=self.hs.config.experimental.msc4115_membership_on_events,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
events.sort(key=lambda e: -rank_map[e.event_id])
|
events.sort(key=lambda e: -rank_map[e.event_id])
|
||||||
|
@ -585,7 +584,6 @@ class SearchHandler:
|
||||||
self._storage_controllers,
|
self._storage_controllers,
|
||||||
user.to_string(),
|
user.to_string(),
|
||||||
filtered_events,
|
filtered_events,
|
||||||
msc4115_membership_on_events=self.hs.config.experimental.msc4115_membership_on_events,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
room_events.extend(events)
|
room_events.extend(events)
|
||||||
|
@ -673,14 +671,12 @@ class SearchHandler:
|
||||||
self._storage_controllers,
|
self._storage_controllers,
|
||||||
user.to_string(),
|
user.to_string(),
|
||||||
res.events_before,
|
res.events_before,
|
||||||
msc4115_membership_on_events=self.hs.config.experimental.msc4115_membership_on_events,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
events_after = await filter_events_for_client(
|
events_after = await filter_events_for_client(
|
||||||
self._storage_controllers,
|
self._storage_controllers,
|
||||||
user.to_string(),
|
user.to_string(),
|
||||||
res.events_after,
|
res.events_after,
|
||||||
msc4115_membership_on_events=self.hs.config.experimental.msc4115_membership_on_events,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
context: JsonDict = {
|
context: JsonDict = {
|
||||||
|
|
|
@ -22,7 +22,7 @@ from typing import TYPE_CHECKING, AbstractSet, Dict, List, Optional
|
||||||
|
|
||||||
from immutabledict import immutabledict
|
from immutabledict import immutabledict
|
||||||
|
|
||||||
from synapse.api.constants import Membership
|
from synapse.api.constants import AccountDataTypes, Membership
|
||||||
from synapse.events import EventBase
|
from synapse.events import EventBase
|
||||||
from synapse.types import Requester, RoomStreamToken, StreamToken, UserID
|
from synapse.types import Requester, RoomStreamToken, StreamToken, UserID
|
||||||
from synapse.types.handlers import OperationType, SlidingSyncConfig, SlidingSyncResult
|
from synapse.types.handlers import OperationType, SlidingSyncConfig, SlidingSyncResult
|
||||||
|
@ -69,9 +69,19 @@ class SlidingSyncHandler:
|
||||||
from_token: Optional[StreamToken] = None,
|
from_token: Optional[StreamToken] = None,
|
||||||
timeout_ms: int = 0,
|
timeout_ms: int = 0,
|
||||||
) -> SlidingSyncResult:
|
) -> SlidingSyncResult:
|
||||||
"""Get the sync for a client if we have new data for it now. Otherwise
|
"""
|
||||||
|
Get the sync for a client if we have new data for it now. Otherwise
|
||||||
wait for new data to arrive on the server. If the timeout expires, then
|
wait for new data to arrive on the server. If the timeout expires, then
|
||||||
return an empty sync result.
|
return an empty sync result.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
requester: The user making the request
|
||||||
|
sync_config: Sync configuration
|
||||||
|
from_token: The point in the stream to sync from. Token of the end of the
|
||||||
|
previous batch. May be `None` if this is the initial sync request.
|
||||||
|
timeout_ms: The time in milliseconds to wait for new data to arrive. If 0,
|
||||||
|
we will immediately but there might not be any new data so we just return an
|
||||||
|
empty response.
|
||||||
"""
|
"""
|
||||||
# If the user is not part of the mau group, then check that limits have
|
# If the user is not part of the mau group, then check that limits have
|
||||||
# not been exceeded (if not part of the group by this point, almost certain
|
# not been exceeded (if not part of the group by this point, almost certain
|
||||||
|
@ -143,6 +153,14 @@ class SlidingSyncHandler:
|
||||||
"""
|
"""
|
||||||
Generates the response body of a Sliding Sync result, represented as a
|
Generates the response body of a Sliding Sync result, represented as a
|
||||||
`SlidingSyncResult`.
|
`SlidingSyncResult`.
|
||||||
|
|
||||||
|
We fetch data according to the token range (> `from_token` and <= `to_token`).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
sync_config: Sync configuration
|
||||||
|
to_token: The point in the stream to sync up to.
|
||||||
|
from_token: The point in the stream to sync from. Token of the end of the
|
||||||
|
previous batch. May be `None` if this is the initial sync request.
|
||||||
"""
|
"""
|
||||||
user_id = sync_config.user.to_string()
|
user_id = sync_config.user.to_string()
|
||||||
app_service = self.store.get_app_service_by_user_id(user_id)
|
app_service = self.store.get_app_service_by_user_id(user_id)
|
||||||
|
@ -163,11 +181,12 @@ class SlidingSyncHandler:
|
||||||
lists: Dict[str, SlidingSyncResult.SlidingWindowList] = {}
|
lists: Dict[str, SlidingSyncResult.SlidingWindowList] = {}
|
||||||
if sync_config.lists:
|
if sync_config.lists:
|
||||||
for list_key, list_config in sync_config.lists.items():
|
for list_key, list_config in sync_config.lists.items():
|
||||||
# TODO: Apply filters
|
# Apply filters
|
||||||
#
|
|
||||||
# TODO: Exclude partially stated rooms unless the `required_state` has
|
|
||||||
# `["m.room.member", "$LAZY"]`
|
|
||||||
filtered_room_ids = room_id_set
|
filtered_room_ids = room_id_set
|
||||||
|
if list_config.filters is not None:
|
||||||
|
filtered_room_ids = await self.filter_rooms(
|
||||||
|
sync_config.user, room_id_set, list_config.filters, to_token
|
||||||
|
)
|
||||||
# TODO: Apply sorts
|
# TODO: Apply sorts
|
||||||
sorted_room_ids = sorted(filtered_room_ids)
|
sorted_room_ids = sorted(filtered_room_ids)
|
||||||
|
|
||||||
|
@ -217,6 +236,12 @@ class SlidingSyncHandler:
|
||||||
`forgotten` flag to the `room_memberships` table in Synapse. There isn't a way
|
`forgotten` flag to the `room_memberships` table in Synapse. There isn't a way
|
||||||
to tell when a room was forgotten at the moment so we can't factor it into the
|
to tell when a room was forgotten at the moment so we can't factor it into the
|
||||||
from/to range.
|
from/to range.
|
||||||
|
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user: User to fetch rooms for
|
||||||
|
to_token: The token to fetch rooms up to.
|
||||||
|
from_token: The point in the stream to sync from.
|
||||||
"""
|
"""
|
||||||
user_id = user.to_string()
|
user_id = user.to_string()
|
||||||
|
|
||||||
|
@ -275,12 +300,6 @@ class SlidingSyncHandler:
|
||||||
instance_map=immutabledict(instance_to_max_stream_ordering_map),
|
instance_map=immutabledict(instance_to_max_stream_ordering_map),
|
||||||
)
|
)
|
||||||
|
|
||||||
# If our `to_token` is already the same or ahead of the latest room membership
|
|
||||||
# for the user, we can just straight-up return the room list (nothing has
|
|
||||||
# changed)
|
|
||||||
if membership_snapshot_token.is_before_or_eq(to_token.room_key):
|
|
||||||
return sync_room_id_set
|
|
||||||
|
|
||||||
# Since we fetched the users room list at some point in time after the from/to
|
# Since we fetched the users room list at some point in time after the from/to
|
||||||
# tokens, we need to revert/rewind some membership changes to match the point in
|
# tokens, we need to revert/rewind some membership changes to match the point in
|
||||||
# time of the `to_token`. In particular, we need to make these fixups:
|
# time of the `to_token`. In particular, we need to make these fixups:
|
||||||
|
@ -300,14 +319,20 @@ class SlidingSyncHandler:
|
||||||
|
|
||||||
# 1) Fetch membership changes that fall in the range from `to_token` up to
|
# 1) Fetch membership changes that fall in the range from `to_token` up to
|
||||||
# `membership_snapshot_token`
|
# `membership_snapshot_token`
|
||||||
membership_change_events_after_to_token = (
|
#
|
||||||
await self.store.get_membership_changes_for_user(
|
# If our `to_token` is already the same or ahead of the latest room membership
|
||||||
user_id,
|
# for the user, we don't need to do any "2)" fix-ups and can just straight-up
|
||||||
from_key=to_token.room_key,
|
# use the room list from the snapshot as a base (nothing has changed)
|
||||||
to_key=membership_snapshot_token,
|
membership_change_events_after_to_token = []
|
||||||
excluded_rooms=self.rooms_to_exclude_globally,
|
if not membership_snapshot_token.is_before_or_eq(to_token.room_key):
|
||||||
|
membership_change_events_after_to_token = (
|
||||||
|
await self.store.get_membership_changes_for_user(
|
||||||
|
user_id,
|
||||||
|
from_key=to_token.room_key,
|
||||||
|
to_key=membership_snapshot_token,
|
||||||
|
excluded_rooms=self.rooms_to_exclude_globally,
|
||||||
|
)
|
||||||
)
|
)
|
||||||
)
|
|
||||||
|
|
||||||
# 1) Assemble a list of the last membership events in some given ranges. Someone
|
# 1) Assemble a list of the last membership events in some given ranges. Someone
|
||||||
# could have left and joined multiple times during the given range but we only
|
# could have left and joined multiple times during the given range but we only
|
||||||
|
@ -439,3 +464,84 @@ class SlidingSyncHandler:
|
||||||
sync_room_id_set.add(room_id)
|
sync_room_id_set.add(room_id)
|
||||||
|
|
||||||
return sync_room_id_set
|
return sync_room_id_set
|
||||||
|
|
||||||
|
async def filter_rooms(
|
||||||
|
self,
|
||||||
|
user: UserID,
|
||||||
|
room_id_set: AbstractSet[str],
|
||||||
|
filters: SlidingSyncConfig.SlidingSyncList.Filters,
|
||||||
|
to_token: StreamToken,
|
||||||
|
) -> AbstractSet[str]:
|
||||||
|
"""
|
||||||
|
Filter rooms based on the sync request.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user: User to filter rooms for
|
||||||
|
room_id_set: Set of room IDs to filter down
|
||||||
|
filters: Filters to apply
|
||||||
|
to_token: We filter based on the state of the room at this token
|
||||||
|
"""
|
||||||
|
user_id = user.to_string()
|
||||||
|
|
||||||
|
# TODO: Apply filters
|
||||||
|
#
|
||||||
|
# TODO: Exclude partially stated rooms unless the `required_state` has
|
||||||
|
# `["m.room.member", "$LAZY"]`
|
||||||
|
|
||||||
|
filtered_room_id_set = set(room_id_set)
|
||||||
|
|
||||||
|
# Filter for Direct-Message (DM) rooms
|
||||||
|
if filters.is_dm is not None:
|
||||||
|
# We're using global account data (`m.direct`) instead of checking for
|
||||||
|
# `is_direct` on membership events because that property only appears for
|
||||||
|
# the invitee membership event (doesn't show up for the inviter). Account
|
||||||
|
# data is set by the client so it needs to be scrutinized.
|
||||||
|
#
|
||||||
|
# We're unable to take `to_token` into account for global account data since
|
||||||
|
# we only keep track of the latest account data for the user.
|
||||||
|
dm_map = await self.store.get_global_account_data_by_type_for_user(
|
||||||
|
user_id, AccountDataTypes.DIRECT
|
||||||
|
)
|
||||||
|
|
||||||
|
# Flatten out the map
|
||||||
|
dm_room_id_set = set()
|
||||||
|
if dm_map:
|
||||||
|
for room_ids in dm_map.values():
|
||||||
|
# Account data should be a list of room IDs. Ignore anything else
|
||||||
|
if isinstance(room_ids, list):
|
||||||
|
for room_id in room_ids:
|
||||||
|
if isinstance(room_id, str):
|
||||||
|
dm_room_id_set.add(room_id)
|
||||||
|
|
||||||
|
if filters.is_dm:
|
||||||
|
# Only DM rooms please
|
||||||
|
filtered_room_id_set = filtered_room_id_set.intersection(dm_room_id_set)
|
||||||
|
else:
|
||||||
|
# Only non-DM rooms please
|
||||||
|
filtered_room_id_set = filtered_room_id_set.difference(dm_room_id_set)
|
||||||
|
|
||||||
|
if filters.spaces:
|
||||||
|
raise NotImplementedError()
|
||||||
|
|
||||||
|
if filters.is_encrypted:
|
||||||
|
raise NotImplementedError()
|
||||||
|
|
||||||
|
if filters.is_invite:
|
||||||
|
raise NotImplementedError()
|
||||||
|
|
||||||
|
if filters.room_types:
|
||||||
|
raise NotImplementedError()
|
||||||
|
|
||||||
|
if filters.not_room_types:
|
||||||
|
raise NotImplementedError()
|
||||||
|
|
||||||
|
if filters.room_name_like:
|
||||||
|
raise NotImplementedError()
|
||||||
|
|
||||||
|
if filters.tags:
|
||||||
|
raise NotImplementedError()
|
||||||
|
|
||||||
|
if filters.not_tags:
|
||||||
|
raise NotImplementedError()
|
||||||
|
|
||||||
|
return filtered_room_id_set
|
||||||
|
|
|
@ -844,7 +844,6 @@ class SyncHandler:
|
||||||
sync_config.user.to_string(),
|
sync_config.user.to_string(),
|
||||||
recents,
|
recents,
|
||||||
always_include_ids=current_state_ids,
|
always_include_ids=current_state_ids,
|
||||||
msc4115_membership_on_events=self.hs_config.experimental.msc4115_membership_on_events,
|
|
||||||
)
|
)
|
||||||
log_kv({"recents_after_visibility_filtering": len(recents)})
|
log_kv({"recents_after_visibility_filtering": len(recents)})
|
||||||
else:
|
else:
|
||||||
|
@ -930,7 +929,6 @@ class SyncHandler:
|
||||||
sync_config.user.to_string(),
|
sync_config.user.to_string(),
|
||||||
loaded_recents,
|
loaded_recents,
|
||||||
always_include_ids=current_state_ids,
|
always_include_ids=current_state_ids,
|
||||||
msc4115_membership_on_events=self.hs_config.experimental.msc4115_membership_on_events,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
loaded_recents = []
|
loaded_recents = []
|
||||||
|
|
|
@ -721,7 +721,6 @@ class Notifier:
|
||||||
user.to_string(),
|
user.to_string(),
|
||||||
new_events,
|
new_events,
|
||||||
is_peeking=is_peeking,
|
is_peeking=is_peeking,
|
||||||
msc4115_membership_on_events=self.hs.config.experimental.msc4115_membership_on_events,
|
|
||||||
)
|
)
|
||||||
elif keyname == StreamKeyType.PRESENCE:
|
elif keyname == StreamKeyType.PRESENCE:
|
||||||
now = self.clock.time_msec()
|
now = self.clock.time_msec()
|
||||||
|
|
|
@ -532,7 +532,6 @@ class Mailer:
|
||||||
self._storage_controllers,
|
self._storage_controllers,
|
||||||
user_id,
|
user_id,
|
||||||
results.events_before,
|
results.events_before,
|
||||||
msc4115_membership_on_events=self.hs.config.experimental.msc4115_membership_on_events,
|
|
||||||
)
|
)
|
||||||
the_events.append(notif_event)
|
the_events.append(notif_event)
|
||||||
|
|
||||||
|
|
|
@ -21,7 +21,7 @@
|
||||||
from typing import TYPE_CHECKING, Callable
|
from typing import TYPE_CHECKING, Callable
|
||||||
|
|
||||||
from synapse.http.server import HttpServer, JsonResource
|
from synapse.http.server import HttpServer, JsonResource
|
||||||
from synapse.rest import admin
|
from synapse.rest import admin, scim
|
||||||
from synapse.rest.client import (
|
from synapse.rest.client import (
|
||||||
account,
|
account,
|
||||||
account_data,
|
account_data,
|
||||||
|
@ -145,6 +145,7 @@ class ClientRestResource(JsonResource):
|
||||||
password_policy.register_servlets(hs, client_resource)
|
password_policy.register_servlets(hs, client_resource)
|
||||||
knock.register_servlets(hs, client_resource)
|
knock.register_servlets(hs, client_resource)
|
||||||
appservice_ping.register_servlets(hs, client_resource)
|
appservice_ping.register_servlets(hs, client_resource)
|
||||||
|
scim.register_servlets(hs, client_resource)
|
||||||
|
|
||||||
# moving to /_synapse/admin
|
# moving to /_synapse/admin
|
||||||
if is_main_process:
|
if is_main_process:
|
||||||
|
|
|
@ -107,7 +107,15 @@ class ReportEventRestServlet(RestServlet):
|
||||||
|
|
||||||
|
|
||||||
class ReportRoomRestServlet(RestServlet):
|
class ReportRoomRestServlet(RestServlet):
|
||||||
# https://github.com/matrix-org/matrix-spec-proposals/pull/4151
|
"""This endpoint lets clients report a room for abuse.
|
||||||
|
|
||||||
|
Whilst MSC4151 is not yet merged, this unstable endpoint is enabled on matrix.org
|
||||||
|
for content moderation purposes, and therefore backwards compatibility should be
|
||||||
|
carefully considered when changing anything on this endpoint.
|
||||||
|
|
||||||
|
More details on the MSC: https://github.com/matrix-org/matrix-spec-proposals/pull/4151
|
||||||
|
"""
|
||||||
|
|
||||||
PATTERNS = client_patterns(
|
PATTERNS = client_patterns(
|
||||||
"/org.matrix.msc4151/rooms/(?P<room_id>[^/]*)/report$",
|
"/org.matrix.msc4151/rooms/(?P<room_id>[^/]*)/report$",
|
||||||
releases=[],
|
releases=[],
|
||||||
|
|
442
synapse/rest/scim.py
Normal file
442
synapse/rest/scim.py
Normal file
|
@ -0,0 +1,442 @@
|
||||||
|
"""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, Tuple
|
||||||
|
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
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
|
||||||
|
|
||||||
|
from .scim_constants import (
|
||||||
|
RESOURCE_TYPE_USER,
|
||||||
|
SCHEMA_RESOURCE_TYPE,
|
||||||
|
SCHEMA_SCHEMA,
|
||||||
|
SCHEMA_SERVICE_PROVIDER_CONFIG,
|
||||||
|
SCHEMA_USER,
|
||||||
|
SCIM_SERVICE_PROVIDER_CONFIG,
|
||||||
|
)
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
|
||||||
|
# TODO: test requests with additional/wrong attributes
|
||||||
|
# TODO: take inspiration from tests/rest/admin/test_user.py
|
||||||
|
# TODO: test user passwords after creation/update
|
||||||
|
|
||||||
|
|
||||||
|
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 absolute_meta_location(self, payload: JsonDict) -> JsonDict:
|
||||||
|
prefix = self.config.server.public_baseurl + SCIM_PREFIX
|
||||||
|
if not payload["meta"]["location"].startswith(prefix):
|
||||||
|
payload["meta"]["location"] = prefix + payload["meta"]["location"]
|
||||||
|
return payload
|
||||||
|
|
||||||
|
def make_list_response_payload(
|
||||||
|
self, items, start_index=1, count=None, total_results=None
|
||||||
|
):
|
||||||
|
return {
|
||||||
|
"schemas": ["urn:ietf:params:scim:api:messages:2.0:ListResponse"],
|
||||||
|
"totalResults": total_results or len(items),
|
||||||
|
"itemsPerPage": count or len(items),
|
||||||
|
"startIndex": start_index,
|
||||||
|
"Resources": items,
|
||||||
|
}
|
||||||
|
|
||||||
|
def make_error_response(self, status, message):
|
||||||
|
return status, {
|
||||||
|
"schemas": ["urn:ietf:params:scim:api:messages:2.0:Error"],
|
||||||
|
"status": status.value if isinstance(status, HTTPStatus) else status,
|
||||||
|
"detail": message,
|
||||||
|
}
|
||||||
|
|
||||||
|
def parse_pagination_params(self, request):
|
||||||
|
start_index = parse_integer(request, "startIndex", default=1, negative=True)
|
||||||
|
count = parse_integer(
|
||||||
|
request, "count", default=self.default_nb_items_per_page, negative=True
|
||||||
|
)
|
||||||
|
|
||||||
|
# RFC7644 §3.4.2.4
|
||||||
|
# A value less than 1 SHALL be interpreted as 1.
|
||||||
|
#
|
||||||
|
# https://datatracker.ietf.org/doc/html/rfc7644#section-3.4.2.4
|
||||||
|
if start_index < 1:
|
||||||
|
start_index = 1
|
||||||
|
|
||||||
|
# RFC7644 §3.4.2.4
|
||||||
|
# A negative value SHALL be interpreted as 0.
|
||||||
|
#
|
||||||
|
# https://datatracker.ietf.org/doc/html/rfc7644#section-3.4.2.4
|
||||||
|
if count < 0:
|
||||||
|
count = 0
|
||||||
|
|
||||||
|
return start_index, count
|
||||||
|
|
||||||
|
async def get_user_data(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",
|
||||||
|
)
|
||||||
|
|
||||||
|
location = f"{self.config.server.public_baseurl}{SCIM_PREFIX}/Users/{user_id}"
|
||||||
|
creation_datetime = datetime.datetime.fromtimestamp(user.creation_ts)
|
||||||
|
payload = {
|
||||||
|
"schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"],
|
||||||
|
"meta": {
|
||||||
|
"resourceType": "User",
|
||||||
|
"created": creation_datetime.isoformat(),
|
||||||
|
"lastModified": creation_datetime.isoformat(),
|
||||||
|
"location": location,
|
||||||
|
},
|
||||||
|
"id": user_id,
|
||||||
|
"externalId": user_id,
|
||||||
|
"userName": user_id_obj.localpart,
|
||||||
|
"active": not user.is_deactivated,
|
||||||
|
}
|
||||||
|
|
||||||
|
for threepid in threepids:
|
||||||
|
if threepid.medium == "email":
|
||||||
|
payload.setdefault("emails", []).append({"value": threepid.address})
|
||||||
|
|
||||||
|
if threepid.medium == "msisdn":
|
||||||
|
payload.setdefault("phoneNumbers", []).append(
|
||||||
|
{"value": threepid.address}
|
||||||
|
)
|
||||||
|
|
||||||
|
if profile.display_name:
|
||||||
|
payload["displayName"] = profile.display_name
|
||||||
|
|
||||||
|
if profile.avatar_url:
|
||||||
|
payload["photos"] = [{
|
||||||
|
"type": "photo",
|
||||||
|
"primary": True,
|
||||||
|
"value": profile.avatar_url,
|
||||||
|
}]
|
||||||
|
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
class UserServlet(SCIMServlet):
|
||||||
|
PATTERNS = [re.compile(f"^/{SCIM_PREFIX}/Users/(?P<user_id>[^/]*)")]
|
||||||
|
|
||||||
|
async def on_GET(
|
||||||
|
self, request: SynapseRequest, user_id: str
|
||||||
|
) -> Tuple[int, JsonDict]:
|
||||||
|
await assert_requester_is_admin(self.auth, request)
|
||||||
|
try:
|
||||||
|
payload = await self.get_user_data(user_id)
|
||||||
|
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
|
||||||
|
)
|
||||||
|
|
||||||
|
payload = await self.get_user_data(user_id)
|
||||||
|
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)
|
||||||
|
start_index, count = self.parse_pagination_params(request)
|
||||||
|
|
||||||
|
items, total = await self.store.get_users_paginate(
|
||||||
|
start=start_index - 1,
|
||||||
|
limit=count,
|
||||||
|
)
|
||||||
|
users = [await self.get_user_data(item.name) for item in items]
|
||||||
|
payload = self.make_list_response_payload(
|
||||||
|
users, start_index=start_index, count=count, total_results=total
|
||||||
|
)
|
||||||
|
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
|
||||||
|
)
|
||||||
|
|
||||||
|
payload = await self.get_user_data(user_id)
|
||||||
|
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]:
|
||||||
|
return HTTPStatus.OK, SCIM_SERVICE_PROVIDER_CONFIG
|
||||||
|
|
||||||
|
|
||||||
|
class SchemaListServlet(SCIMServlet):
|
||||||
|
PATTERNS = [re.compile(f"^/{SCIM_PREFIX}/Schemas$")]
|
||||||
|
|
||||||
|
async def on_GET(self, request: SynapseRequest) -> Tuple[int, JsonDict]:
|
||||||
|
start_index, count = self.parse_pagination_params(request)
|
||||||
|
resources = [
|
||||||
|
self.absolute_meta_location(SCHEMA_SERVICE_PROVIDER_CONFIG),
|
||||||
|
self.absolute_meta_location(SCHEMA_RESOURCE_TYPE),
|
||||||
|
self.absolute_meta_location(SCHEMA_SCHEMA),
|
||||||
|
self.absolute_meta_location(SCHEMA_USER),
|
||||||
|
]
|
||||||
|
return HTTPStatus.OK, self.make_list_response_payload(
|
||||||
|
resources, start_index=start_index, count=count
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class SchemaServlet(SCIMServlet):
|
||||||
|
PATTERNS = [re.compile(f"^/{SCIM_PREFIX}/Schemas/(?P<schema_id>[^/]*)$")]
|
||||||
|
|
||||||
|
async def on_GET(
|
||||||
|
self, request: SynapseRequest, schema_id: str
|
||||||
|
) -> Tuple[int, JsonDict]:
|
||||||
|
schemas = {
|
||||||
|
"urn:ietf:params:scim:schemas:core:2.0:ServiceProviderConfig": SCHEMA_SERVICE_PROVIDER_CONFIG,
|
||||||
|
"urn:ietf:params:scim:schemas:core:2.0:ResourceType": SCHEMA_RESOURCE_TYPE,
|
||||||
|
"urn:ietf:params:scim:schemas:core:2.0:Schema": SCHEMA_SCHEMA,
|
||||||
|
"urn:ietf:params:scim:schemas:core:2.0:User": SCHEMA_USER,
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
return HTTPStatus.OK, self.absolute_meta_location(schemas[schema_id])
|
||||||
|
except KeyError:
|
||||||
|
return self.make_error_response(HTTPStatus.NOT_FOUND, "Object not found")
|
||||||
|
|
||||||
|
|
||||||
|
class ResourceTypeListServlet(SCIMServlet):
|
||||||
|
PATTERNS = [re.compile(f"^/{SCIM_PREFIX}/ResourceTypes$")]
|
||||||
|
|
||||||
|
async def on_GET(self, request: SynapseRequest) -> Tuple[int, JsonDict]:
|
||||||
|
start_index, count = self.parse_pagination_params(request)
|
||||||
|
resources = [self.absolute_meta_location(RESOURCE_TYPE_USER)]
|
||||||
|
return HTTPStatus.OK, self.make_list_response_payload(
|
||||||
|
resources, start_index=start_index, count=count
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ResourceTypeServlet(SCIMServlet):
|
||||||
|
PATTERNS = [re.compile(f"^/{SCIM_PREFIX}/ResourceTypes/(?P<resource_type>[^/]*)$")]
|
||||||
|
|
||||||
|
async def on_GET(
|
||||||
|
self, request: SynapseRequest, resource_type: str
|
||||||
|
) -> Tuple[int, JsonDict]:
|
||||||
|
resource_types = {
|
||||||
|
"User": RESOURCE_TYPE_USER,
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
return HTTPStatus.OK, self.absolute_meta_location(
|
||||||
|
resource_types[resource_type]
|
||||||
|
)
|
||||||
|
except KeyError:
|
||||||
|
return self.make_error_response(HTTPStatus.NOT_FOUND, "Object not found")
|
824
synapse/rest/scim_constants.py
Normal file
824
synapse/rest/scim_constants.py
Normal file
|
@ -0,0 +1,824 @@
|
||||||
|
SCIM_SERVICE_PROVIDER_CONFIG = {
|
||||||
|
"schemas": ["urn:ietf:params:scim:schemas:core:2.0:ServiceProviderConfig"],
|
||||||
|
"meta": {
|
||||||
|
"location": "/ServiceProviderConfig",
|
||||||
|
"resourceType": "ServiceProviderConfig",
|
||||||
|
},
|
||||||
|
"documentationUri": "https://element-hq.github.io/synapse/latest/admin_api/scim_api.html",
|
||||||
|
"patch": {"supported": False},
|
||||||
|
"bulk": {"supported": False, "maxOperations": 0, "maxPayloadSize": 0},
|
||||||
|
"changePassword": {"supported": True},
|
||||||
|
"filter": {"supported": False, "maxResults": 0},
|
||||||
|
"sort": {"supported": False},
|
||||||
|
"etag": {"supported": False},
|
||||||
|
"authenticationSchemes": [
|
||||||
|
{
|
||||||
|
"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, # TODO
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"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",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
SCHEMA_SERVICE_PROVIDER_CONFIG = {
|
||||||
|
"schemas": ["urn:ietf:params:scim:schemas:core:2.0:Schema"],
|
||||||
|
"id": "urn:ietf:params:scim:schemas:core:2.0:ServiceProviderConfig",
|
||||||
|
"meta": {
|
||||||
|
"resourceType": "Schema",
|
||||||
|
"location": "/Schemas/urn:ietf:params:scim:schemas:core:2.0:ServiceProviderConfig",
|
||||||
|
},
|
||||||
|
"name": "Service Provider Configuration",
|
||||||
|
"description": """Schema for representing the service provider's configuration""",
|
||||||
|
"attributes": [
|
||||||
|
{
|
||||||
|
"name": "documentationUri",
|
||||||
|
"type": "reference",
|
||||||
|
"referenceTypes": ["external"],
|
||||||
|
"multiValued": False,
|
||||||
|
"description": """An HTTP-addressable URL pointing to the service provider's human-consumable help documentation.""",
|
||||||
|
"required": False,
|
||||||
|
"caseExact": False,
|
||||||
|
"mutability": "readOnly",
|
||||||
|
"returned": "default",
|
||||||
|
"uniqueness": "none",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "patch",
|
||||||
|
"type": "complex",
|
||||||
|
"multiValued": False,
|
||||||
|
"description": """A complex type that specifies PATCH configuration options.""",
|
||||||
|
"required": True,
|
||||||
|
"returned": "default",
|
||||||
|
"mutability": "readOnly",
|
||||||
|
"subAttributes": [
|
||||||
|
{
|
||||||
|
"name": "supported",
|
||||||
|
"type": "boolean",
|
||||||
|
"multiValued": False,
|
||||||
|
"description": """A Boolean value specifying whether or not the operation is supported.""",
|
||||||
|
"required": True,
|
||||||
|
"mutability": "readOnly",
|
||||||
|
"returned": "default",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "bulk",
|
||||||
|
"type": "complex",
|
||||||
|
"multiValued": False,
|
||||||
|
"description": """A complex type that specifies bulk configuration options.""",
|
||||||
|
"required": True,
|
||||||
|
"returned": "default",
|
||||||
|
"mutability": "readOnly",
|
||||||
|
"subAttributes": [
|
||||||
|
{
|
||||||
|
"name": "supported",
|
||||||
|
"type": "boolean",
|
||||||
|
"multiValued": False,
|
||||||
|
"description": """A Boolean value specifying whether or not the operation is supported.""",
|
||||||
|
"required": True,
|
||||||
|
"mutability": "readOnly",
|
||||||
|
"returned": "default",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "maxOperations",
|
||||||
|
"type": "integer",
|
||||||
|
"multiValued": False,
|
||||||
|
"description": """An integer value specifying the maximum number of operations.""",
|
||||||
|
"required": True,
|
||||||
|
"mutability": "readOnly",
|
||||||
|
"returned": "default",
|
||||||
|
"uniqueness": "none",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "maxPayloadSize",
|
||||||
|
"type": "integer",
|
||||||
|
"multiValued": False,
|
||||||
|
"description": """An integer value specifying the maximum payload size in bytes.""",
|
||||||
|
"required": True,
|
||||||
|
"mutability": "readOnly",
|
||||||
|
"returned": "default",
|
||||||
|
"uniqueness": "none",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "filter",
|
||||||
|
"type": "complex",
|
||||||
|
"multiValued": False,
|
||||||
|
"description": """A complex type that specifies FILTER options.""",
|
||||||
|
"required": True,
|
||||||
|
"returned": "default",
|
||||||
|
"mutability": "readOnly",
|
||||||
|
"subAttributes": [
|
||||||
|
{
|
||||||
|
"name": "supported",
|
||||||
|
"type": "boolean",
|
||||||
|
"multiValued": False,
|
||||||
|
"description": """A Boolean value specifying whether or not the operation is supported.""",
|
||||||
|
"required": True,
|
||||||
|
"mutability": "readOnly",
|
||||||
|
"returned": "default",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "maxResults",
|
||||||
|
"type": "integer",
|
||||||
|
"multiValued": False,
|
||||||
|
"description": """An integer value specifying the maximum number of resources returned in a response.""",
|
||||||
|
"required": True,
|
||||||
|
"mutability": "readOnly",
|
||||||
|
"returned": "default",
|
||||||
|
"uniqueness": "none",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "changePassword",
|
||||||
|
"type": "complex",
|
||||||
|
"multiValued": False,
|
||||||
|
"description": """A complex type that specifies configuration options related to changing a password.""",
|
||||||
|
"required": True,
|
||||||
|
"returned": "default",
|
||||||
|
"mutability": "readOnly",
|
||||||
|
"subAttributes": [
|
||||||
|
{
|
||||||
|
"name": "supported",
|
||||||
|
"type": "boolean",
|
||||||
|
"multiValued": False,
|
||||||
|
"description": """A Boolean value specifying whether or not the operation is supported.""",
|
||||||
|
"required": True,
|
||||||
|
"mutability": "readOnly",
|
||||||
|
"returned": "default",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "sort",
|
||||||
|
"type": "complex",
|
||||||
|
"multiValued": False,
|
||||||
|
"description": """A complex type that specifies sort result options.""",
|
||||||
|
"required": True,
|
||||||
|
"returned": "default",
|
||||||
|
"mutability": "readOnly",
|
||||||
|
"subAttributes": [
|
||||||
|
{
|
||||||
|
"name": "supported",
|
||||||
|
"type": "boolean",
|
||||||
|
"multiValued": False,
|
||||||
|
"description": """A Boolean value specifying whether or not the operation is supported.""",
|
||||||
|
"required": True,
|
||||||
|
"mutability": "readOnly",
|
||||||
|
"returned": "default",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "authenticationSchemes",
|
||||||
|
"type": "complex",
|
||||||
|
"multiValued": True,
|
||||||
|
"description": """A complex type that specifies supported authentication scheme properties.""",
|
||||||
|
"required": True,
|
||||||
|
"returned": "default",
|
||||||
|
"mutability": "readOnly",
|
||||||
|
"subAttributes": [
|
||||||
|
{
|
||||||
|
"name": "name",
|
||||||
|
"type": "string",
|
||||||
|
"multiValued": False,
|
||||||
|
"description": """The common authentication scheme name, e.g., HTTP Basic.""",
|
||||||
|
"required": True,
|
||||||
|
"caseExact": False,
|
||||||
|
"mutability": "readOnly",
|
||||||
|
"returned": "default",
|
||||||
|
"uniqueness": "none",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "description",
|
||||||
|
"type": "string",
|
||||||
|
"multiValued": False,
|
||||||
|
"description": """A description of the authentication scheme.""",
|
||||||
|
"required": True,
|
||||||
|
"caseExact": False,
|
||||||
|
"mutability": "readOnly",
|
||||||
|
"returned": "default",
|
||||||
|
"uniqueness": "none",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "specUri",
|
||||||
|
"type": "reference",
|
||||||
|
"referenceTypes": ["external"],
|
||||||
|
"multiValued": False,
|
||||||
|
"description": """An HTTP-addressable URL pointing to the authentication scheme's specification.""",
|
||||||
|
"required": False,
|
||||||
|
"caseExact": False,
|
||||||
|
"mutability": "readOnly",
|
||||||
|
"returned": "default",
|
||||||
|
"uniqueness": "none",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "documentationUri",
|
||||||
|
"type": "reference",
|
||||||
|
"referenceTypes": ["external"],
|
||||||
|
"multiValued": False,
|
||||||
|
"description": """An HTTP-addressable URL pointing to the authentication scheme's usage documentation.""",
|
||||||
|
"required": False,
|
||||||
|
"caseExact": False,
|
||||||
|
"mutability": "readOnly",
|
||||||
|
"returned": "default",
|
||||||
|
"uniqueness": "none",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
SCHEMA_RESOURCE_TYPE = {
|
||||||
|
"schemas": ["urn:ietf:params:scim:schemas:core:2.0:Schema"],
|
||||||
|
"id": "urn:ietf:params:scim:schemas:core:2.0:ResourceType",
|
||||||
|
"meta": {
|
||||||
|
"resourceType": "Schema",
|
||||||
|
"location": "/Schemas/urn:ietf:params:scim:schemas:core:2.0:ResourceType",
|
||||||
|
},
|
||||||
|
"name": "ResourceType",
|
||||||
|
"description": """Specifies the schema that describes a SCIM resource type""",
|
||||||
|
"attributes": [
|
||||||
|
{
|
||||||
|
"name": "id",
|
||||||
|
"type": "string",
|
||||||
|
"multiValued": False,
|
||||||
|
"description": """The resource type's server unique id. May be the same as the 'name' attribute.""",
|
||||||
|
"required": False,
|
||||||
|
"caseExact": False,
|
||||||
|
"mutability": "readOnly",
|
||||||
|
"returned": "default",
|
||||||
|
"uniqueness": "none",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "name",
|
||||||
|
"type": "string",
|
||||||
|
"multiValued": False,
|
||||||
|
"description": """The resource type name. When applicable, service providers MUST specify the name, e.g., 'User'.""",
|
||||||
|
"required": True,
|
||||||
|
"caseExact": False,
|
||||||
|
"mutability": "readOnly",
|
||||||
|
"returned": "default",
|
||||||
|
"uniqueness": "none",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "description",
|
||||||
|
"type": "string",
|
||||||
|
"multiValued": False,
|
||||||
|
"description": """The resource type's human-readable description. When applicable, service providers MUST specify the description.""",
|
||||||
|
"required": False,
|
||||||
|
"caseExact": False,
|
||||||
|
"mutability": "readOnly",
|
||||||
|
"returned": "default",
|
||||||
|
"uniqueness": "none",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "endpoint",
|
||||||
|
"type": "reference",
|
||||||
|
"referenceTypes": ["uri"],
|
||||||
|
"multiValued": False,
|
||||||
|
"description": """The resource type's HTTP-addressable endpoint relative to the Base URL, e.g., '/Users'.""",
|
||||||
|
"required": True,
|
||||||
|
"caseExact": False,
|
||||||
|
"mutability": "readOnly",
|
||||||
|
"returned": "default",
|
||||||
|
"uniqueness": "none",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "schema",
|
||||||
|
"type": "reference",
|
||||||
|
"referenceTypes": ["uri"],
|
||||||
|
"multiValued": False,
|
||||||
|
"description": """The resource type's primary/base schema URI.""",
|
||||||
|
"required": True,
|
||||||
|
"caseExact": True,
|
||||||
|
"mutability": "readOnly",
|
||||||
|
"returned": "default",
|
||||||
|
"uniqueness": "none",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "schemaExtensions",
|
||||||
|
"type": "complex",
|
||||||
|
"multiValued": False,
|
||||||
|
"description": """A list of URIs of the resource type's schema extensions.""",
|
||||||
|
"required": True,
|
||||||
|
"mutability": "readOnly",
|
||||||
|
"returned": "default",
|
||||||
|
"subAttributes": [
|
||||||
|
{
|
||||||
|
"name": "schema",
|
||||||
|
"type": "reference",
|
||||||
|
"referenceTypes": ["uri"],
|
||||||
|
"multiValued": False,
|
||||||
|
"description": "The URI of a schema extension.",
|
||||||
|
"required": True,
|
||||||
|
"caseExact": True,
|
||||||
|
"mutability": "readOnly",
|
||||||
|
"returned": "default",
|
||||||
|
"uniqueness": "none",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "required",
|
||||||
|
"type": "boolean",
|
||||||
|
"multiValued": False,
|
||||||
|
"description": """A Boolean value that specifies whether or not the schema extension is required for the resource type. If True, a resource of this type MUST include this schema extension and also include any attributes declared as required in this schema extension. If False, a resource of this type MAY omit this schema extension.""",
|
||||||
|
"required": True,
|
||||||
|
"mutability": "readOnly",
|
||||||
|
"returned": "default",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
SCHEMA_SCHEMA = {
|
||||||
|
"schemas": ["urn:ietf:params:scim:schemas:core:2.0:Schema"],
|
||||||
|
"id": "urn:ietf:params:scim:schemas:core:2.0:Schema",
|
||||||
|
"meta": {
|
||||||
|
"resourceType": "Schema",
|
||||||
|
"location": "/Schemas/urn:ietf:params:scim:schemas:core:2.0:Schema",
|
||||||
|
},
|
||||||
|
"name": "Schema",
|
||||||
|
"description": """Specifies the schema that describes a SCIM schema""",
|
||||||
|
"attributes": [
|
||||||
|
{
|
||||||
|
"name": "id",
|
||||||
|
"type": "string",
|
||||||
|
"multiValued": False,
|
||||||
|
"description": """The unique URI of the schema. When applicable, service providers MUST specify the URI.""",
|
||||||
|
"required": True,
|
||||||
|
"caseExact": False,
|
||||||
|
"mutability": "readOnly",
|
||||||
|
"returned": "default",
|
||||||
|
"uniqueness": "none",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "name",
|
||||||
|
"type": "string",
|
||||||
|
"multiValued": False,
|
||||||
|
"description": """The schema's human-readable name. When applicable, service providers MUST specify the name, e.g., 'User'.""",
|
||||||
|
"required": True,
|
||||||
|
"caseExact": False,
|
||||||
|
"mutability": "readOnly",
|
||||||
|
"returned": "default",
|
||||||
|
"uniqueness": "none",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "description",
|
||||||
|
"type": "string",
|
||||||
|
"multiValued": False,
|
||||||
|
"description": """The schema's human-readable name. When applicable, service providers MUST specify the name, e.g., 'User'.""",
|
||||||
|
"required": False,
|
||||||
|
"caseExact": False,
|
||||||
|
"mutability": "readOnly",
|
||||||
|
"returned": "default",
|
||||||
|
"uniqueness": "none",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "attributes",
|
||||||
|
"type": "complex",
|
||||||
|
"multiValued": True,
|
||||||
|
"description": """A complex attribute that includes the attributes of a schema.""",
|
||||||
|
"required": True,
|
||||||
|
"mutability": "readOnly",
|
||||||
|
"returned": "default",
|
||||||
|
"subAttributes": [
|
||||||
|
{
|
||||||
|
"name": "name",
|
||||||
|
"type": "string",
|
||||||
|
"multiValued": False,
|
||||||
|
"description": "The attribute's name.",
|
||||||
|
"required": True,
|
||||||
|
"caseExact": True,
|
||||||
|
"mutability": "readOnly",
|
||||||
|
"returned": "default",
|
||||||
|
"uniqueness": "none",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "type",
|
||||||
|
"type": "string",
|
||||||
|
"multiValued": False,
|
||||||
|
"description": """The attribute's data type. Valid values include 'string', 'complex', 'boolean', 'decimal', 'integer', 'dateTime', 'reference'.""",
|
||||||
|
"required": True,
|
||||||
|
"canonicalValues": [
|
||||||
|
"string",
|
||||||
|
"complex",
|
||||||
|
"boolean",
|
||||||
|
"decimal",
|
||||||
|
"integer",
|
||||||
|
"dateTime",
|
||||||
|
"reference",
|
||||||
|
],
|
||||||
|
"caseExact": False,
|
||||||
|
"mutability": "readOnly",
|
||||||
|
"returned": "default",
|
||||||
|
"uniqueness": "none",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "multiValued",
|
||||||
|
"type": "boolean",
|
||||||
|
"multiValued": False,
|
||||||
|
"description": """A Boolean value indicating an attribute's plurality.""",
|
||||||
|
"required": True,
|
||||||
|
"mutability": "readOnly",
|
||||||
|
"returned": "default",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "description",
|
||||||
|
"type": "string",
|
||||||
|
"multiValued": False,
|
||||||
|
"description": """A human-readable description of the attribute.""",
|
||||||
|
"required": False,
|
||||||
|
"caseExact": True,
|
||||||
|
"mutability": "readOnly",
|
||||||
|
"returned": "default",
|
||||||
|
"uniqueness": "none",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "required",
|
||||||
|
"type": "boolean",
|
||||||
|
"multiValued": False,
|
||||||
|
"description": """A boolean value indicating whether or not the attribute is required.""",
|
||||||
|
"required": False,
|
||||||
|
"mutability": "readOnly",
|
||||||
|
"returned": "default",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "canonicalValues",
|
||||||
|
"type": "string",
|
||||||
|
"multiValued": True,
|
||||||
|
"description": """A collection of canonical values. When applicable, service providers MUST specify the canonical types, e.g., 'work', 'home'.""",
|
||||||
|
"required": False,
|
||||||
|
"caseExact": True,
|
||||||
|
"mutability": "readOnly",
|
||||||
|
"returned": "default",
|
||||||
|
"uniqueness": "none",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "caseExact",
|
||||||
|
"type": "boolean",
|
||||||
|
"multiValued": False,
|
||||||
|
"description": """A Boolean value indicating whether or not a string attribute is case sensitive.""",
|
||||||
|
"required": False,
|
||||||
|
"mutability": "readOnly",
|
||||||
|
"returned": "default",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "mutability",
|
||||||
|
"type": "string",
|
||||||
|
"multiValued": False,
|
||||||
|
"description": """Indicates whether or not an attribute is modifiable.""",
|
||||||
|
"required": False,
|
||||||
|
"caseExact": True,
|
||||||
|
"mutability": "readOnly",
|
||||||
|
"returned": "default",
|
||||||
|
"uniqueness": "none",
|
||||||
|
"canonicalValues": [
|
||||||
|
"readOnly",
|
||||||
|
"readWrite",
|
||||||
|
"immutable",
|
||||||
|
"writeOnly",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "returned",
|
||||||
|
"type": "string",
|
||||||
|
"multiValued": False,
|
||||||
|
"description": """Indicates when an attribute is returned in a response (e.g., to a query).""",
|
||||||
|
"required": False,
|
||||||
|
"caseExact": True,
|
||||||
|
"mutability": "readOnly",
|
||||||
|
"returned": "default",
|
||||||
|
"uniqueness": "none",
|
||||||
|
"canonicalValues": [
|
||||||
|
"always",
|
||||||
|
"never",
|
||||||
|
"default",
|
||||||
|
"request",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "uniqueness",
|
||||||
|
"type": "string",
|
||||||
|
"multiValued": False,
|
||||||
|
"description": "Indicates how unique a value must be.",
|
||||||
|
"required": False,
|
||||||
|
"caseExact": True,
|
||||||
|
"mutability": "readOnly",
|
||||||
|
"returned": "default",
|
||||||
|
"uniqueness": "none",
|
||||||
|
"canonicalValues": ["none", "server", "global"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "referenceTypes",
|
||||||
|
"type": "string",
|
||||||
|
"multiValued": True,
|
||||||
|
"description": """Used only with an attribute of type 'reference'. Specifies a SCIM resourceType that a reference attribute MAY refer to, e.g., 'User'.""",
|
||||||
|
"required": False,
|
||||||
|
"caseExact": True,
|
||||||
|
"mutability": "readOnly",
|
||||||
|
"returned": "default",
|
||||||
|
"uniqueness": "none",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "subAttributes",
|
||||||
|
"type": "complex",
|
||||||
|
"multiValued": True,
|
||||||
|
"description": """Used to define the sub-attributes of a complex attribute.""",
|
||||||
|
"required": False,
|
||||||
|
"mutability": "readOnly",
|
||||||
|
"returned": "default",
|
||||||
|
"subAttributes": [
|
||||||
|
{
|
||||||
|
"name": "name",
|
||||||
|
"type": "string",
|
||||||
|
"multiValued": False,
|
||||||
|
"description": "The attribute's name.",
|
||||||
|
"required": True,
|
||||||
|
"caseExact": True,
|
||||||
|
"mutability": "readOnly",
|
||||||
|
"returned": "default",
|
||||||
|
"uniqueness": "none",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "type",
|
||||||
|
"type": "string",
|
||||||
|
"multiValued": False,
|
||||||
|
"description": """The attribute's data type. Valid values include 'string', 'complex', 'boolean', 'decimal', 'integer', 'dateTime', 'reference'.""",
|
||||||
|
"required": True,
|
||||||
|
"caseExact": False,
|
||||||
|
"mutability": "readOnly",
|
||||||
|
"returned": "default",
|
||||||
|
"uniqueness": "none",
|
||||||
|
"canonicalValues": [
|
||||||
|
"string",
|
||||||
|
"complex",
|
||||||
|
"boolean",
|
||||||
|
"decimal",
|
||||||
|
"integer",
|
||||||
|
"dateTime",
|
||||||
|
"reference",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "multiValued",
|
||||||
|
"type": "boolean",
|
||||||
|
"multiValued": False,
|
||||||
|
"description": """A Boolean value indicating an attribute's plurality.""",
|
||||||
|
"required": True,
|
||||||
|
"mutability": "readOnly",
|
||||||
|
"returned": "default",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "description",
|
||||||
|
"type": "string",
|
||||||
|
"multiValued": False,
|
||||||
|
"description": """A human-readable description of the attribute.""",
|
||||||
|
"required": False,
|
||||||
|
"caseExact": True,
|
||||||
|
"mutability": "readOnly",
|
||||||
|
"returned": "default",
|
||||||
|
"uniqueness": "none",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "required",
|
||||||
|
"type": "boolean",
|
||||||
|
"multiValued": False,
|
||||||
|
"description": """A boolean value indicating whether or not the attribute is required.""",
|
||||||
|
"required": False,
|
||||||
|
"mutability": "readOnly",
|
||||||
|
"returned": "default",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "canonicalValues",
|
||||||
|
"type": "string",
|
||||||
|
"multiValued": True,
|
||||||
|
"description": """A collection of canonical values. When applicable, service providers MUST specify the canonical types, e.g., 'work', 'home'.""",
|
||||||
|
"required": False,
|
||||||
|
"caseExact": True,
|
||||||
|
"mutability": "readOnly",
|
||||||
|
"returned": "default",
|
||||||
|
"uniqueness": "none",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "caseExact",
|
||||||
|
"type": "boolean",
|
||||||
|
"multiValued": False,
|
||||||
|
"description": """A Boolean value indicating whether or not a string attribute is case sensitive.""",
|
||||||
|
"required": False,
|
||||||
|
"mutability": "readOnly",
|
||||||
|
"returned": "default",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "mutability",
|
||||||
|
"type": "string",
|
||||||
|
"multiValued": False,
|
||||||
|
"description": """Indicates whether or not an attribute is modifiable.""",
|
||||||
|
"required": False,
|
||||||
|
"caseExact": True,
|
||||||
|
"mutability": "readOnly",
|
||||||
|
"returned": "default",
|
||||||
|
"uniqueness": "none",
|
||||||
|
"canonicalValues": [
|
||||||
|
"readOnly",
|
||||||
|
"readWrite",
|
||||||
|
"immutable",
|
||||||
|
"writeOnly",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "returned",
|
||||||
|
"type": "string",
|
||||||
|
"multiValued": False,
|
||||||
|
"description": """Indicates when an attribute is returned in a response (e.g., to a query).""",
|
||||||
|
"required": False,
|
||||||
|
"caseExact": True,
|
||||||
|
"mutability": "readOnly",
|
||||||
|
"returned": "default",
|
||||||
|
"uniqueness": "none",
|
||||||
|
"canonicalValues": [
|
||||||
|
"always",
|
||||||
|
"never",
|
||||||
|
"default",
|
||||||
|
"request",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "uniqueness",
|
||||||
|
"type": "string",
|
||||||
|
"multiValued": False,
|
||||||
|
"description": "Indicates how unique a value must be.",
|
||||||
|
"required": False,
|
||||||
|
"caseExact": True,
|
||||||
|
"mutability": "readOnly",
|
||||||
|
"returned": "default",
|
||||||
|
"uniqueness": "none",
|
||||||
|
"canonicalValues": ["none", "server", "global"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "referenceTypes",
|
||||||
|
"type": "string",
|
||||||
|
"multiValued": False,
|
||||||
|
"description": """Used only with an attribute of type 'reference'. Specifies a SCIM resourceType that a reference attribute MAY refer to, e.g., 'User'.""",
|
||||||
|
"required": False,
|
||||||
|
"caseExact": True,
|
||||||
|
"mutability": "readOnly",
|
||||||
|
"returned": "default",
|
||||||
|
"uniqueness": "none",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
SCHEMA_USER = {
|
||||||
|
"schemas": ["urn:ietf:params:scim:schemas:core:2.0:Schema"],
|
||||||
|
"id": "urn:ietf:params:scim:schemas:core:2.0:User",
|
||||||
|
"meta": {
|
||||||
|
"resourceType": "Schema",
|
||||||
|
"location": "/Schemas/urn:ietf:params:scim:schemas:core:2.0:User",
|
||||||
|
},
|
||||||
|
"name": "User",
|
||||||
|
"description": "User Account",
|
||||||
|
"attributes": [
|
||||||
|
{
|
||||||
|
"name": "userName",
|
||||||
|
"type": "string",
|
||||||
|
"multiValued": False,
|
||||||
|
"description": """Unique identifier for the User, typically used by the user to directly authenticate to the service provider. Each User MUST include a non-empty userName value. This identifier MUST be unique across the service provider's entire set of Users. REQUIRED.""",
|
||||||
|
"required": True,
|
||||||
|
"caseExact": False,
|
||||||
|
"mutability": "readWrite",
|
||||||
|
"returned": "default",
|
||||||
|
"uniqueness": "server",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "displayName",
|
||||||
|
"type": "string",
|
||||||
|
"multiValued": False,
|
||||||
|
"description": """The name of the User, suitable for display to end-users. The name SHOULD be the full name of the User being described, if known.""",
|
||||||
|
"required": False,
|
||||||
|
"caseExact": False,
|
||||||
|
"mutability": "readWrite",
|
||||||
|
"returned": "default",
|
||||||
|
"uniqueness": "none",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "active",
|
||||||
|
"type": "boolean",
|
||||||
|
"multiValued": False,
|
||||||
|
"description": """A Boolean value indicating the User's administrative status.""",
|
||||||
|
"required": False,
|
||||||
|
"mutability": "readWrite",
|
||||||
|
"returned": "default",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "password",
|
||||||
|
"type": "string",
|
||||||
|
"multiValued": False,
|
||||||
|
"description": """The User's cleartext password. This attribute is intended to be used as a means to specify an initial password when creating a new User or to reset an existing User's password.""",
|
||||||
|
"required": False,
|
||||||
|
"caseExact": False,
|
||||||
|
"mutability": "writeOnly",
|
||||||
|
"returned": "never",
|
||||||
|
"uniqueness": "none",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "emails",
|
||||||
|
"type": "complex",
|
||||||
|
"multiValued": True,
|
||||||
|
"description": """Email addresses for the user. The value SHOULD be canonicalized by the service provider, e.g., 'bjensen@example.com' instead of 'bjensen@EXAMPLE.COM'. Canonical type values of 'work', 'home', and 'other'.""",
|
||||||
|
"required": False,
|
||||||
|
"subAttributes": [
|
||||||
|
{
|
||||||
|
"name": "value",
|
||||||
|
"type": "string",
|
||||||
|
"multiValued": False,
|
||||||
|
"description": """Email addresses for the user. The value SHOULD be canonicalized by the service provider, e.g., 'bjensen@example.com' instead of 'bjensen@EXAMPLE.COM'. Canonical type values of 'work', 'home', and 'other'.""",
|
||||||
|
"required": False,
|
||||||
|
"caseExact": False,
|
||||||
|
"mutability": "readWrite",
|
||||||
|
"returned": "default",
|
||||||
|
"uniqueness": "none",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"mutability": "readWrite",
|
||||||
|
"returned": "default",
|
||||||
|
"uniqueness": "none",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "phoneNumbers",
|
||||||
|
"type": "complex",
|
||||||
|
"multiValued": True,
|
||||||
|
"description": """Phone numbers for the User. The value SHOULD be canonicalized by the service provider according to the format specified in RFC 3966, e.g., 'tel:+1-201-555-0123'. Canonical type values of 'work', 'home', 'mobile', 'fax', 'pager', and 'other'.""",
|
||||||
|
"required": False,
|
||||||
|
"subAttributes": [
|
||||||
|
{
|
||||||
|
"name": "value",
|
||||||
|
"type": "string",
|
||||||
|
"multiValued": False,
|
||||||
|
"description": "Phone number of the User.",
|
||||||
|
"required": False,
|
||||||
|
"caseExact": False,
|
||||||
|
"mutability": "readWrite",
|
||||||
|
"returned": "default",
|
||||||
|
"uniqueness": "none",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"mutability": "readWrite",
|
||||||
|
"returned": "default",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "photos",
|
||||||
|
"type": "complex",
|
||||||
|
"multiValued": True,
|
||||||
|
"description": "URLs of photos of the User.",
|
||||||
|
"required": False,
|
||||||
|
"subAttributes": [
|
||||||
|
{
|
||||||
|
"name": "value",
|
||||||
|
"type": "reference",
|
||||||
|
"referenceTypes": ["external"],
|
||||||
|
"multiValued": False,
|
||||||
|
"description": "URL of a photo of the User.",
|
||||||
|
"required": False,
|
||||||
|
"caseExact": False,
|
||||||
|
"mutability": "readWrite",
|
||||||
|
"returned": "default",
|
||||||
|
"uniqueness": "none",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"mutability": "readWrite",
|
||||||
|
"returned": "default",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
RESOURCE_TYPE_USER = {
|
||||||
|
"schemas": ["urn:ietf:params:scim:schemas:core:2.0:ResourceType"],
|
||||||
|
"meta": {
|
||||||
|
"location": "/ResourceTypes/User",
|
||||||
|
"resourceType": "ResourceType",
|
||||||
|
},
|
||||||
|
"id": "User",
|
||||||
|
"name": "User",
|
||||||
|
"endpoint": "/Users",
|
||||||
|
"description": "User Account",
|
||||||
|
"schema": "urn:ietf:params:scim:schemas:core:2.0:User",
|
||||||
|
"schemaExtensions": [],
|
||||||
|
}
|
|
@ -207,6 +207,7 @@ class PersistEventsStore:
|
||||||
async with stream_ordering_manager as stream_orderings:
|
async with stream_ordering_manager as stream_orderings:
|
||||||
for (event, _), stream in zip(events_and_contexts, stream_orderings):
|
for (event, _), stream in zip(events_and_contexts, stream_orderings):
|
||||||
event.internal_metadata.stream_ordering = stream
|
event.internal_metadata.stream_ordering = stream
|
||||||
|
event.internal_metadata.instance_name = self._instance_name
|
||||||
|
|
||||||
await self.db_pool.runInteraction(
|
await self.db_pool.runInteraction(
|
||||||
"persist_events",
|
"persist_events",
|
||||||
|
|
|
@ -156,6 +156,7 @@ class _EventRow:
|
||||||
|
|
||||||
event_id: str
|
event_id: str
|
||||||
stream_ordering: int
|
stream_ordering: int
|
||||||
|
instance_name: str
|
||||||
json: str
|
json: str
|
||||||
internal_metadata: str
|
internal_metadata: str
|
||||||
format_version: Optional[int]
|
format_version: Optional[int]
|
||||||
|
@ -1354,6 +1355,7 @@ class EventsWorkerStore(SQLBaseStore):
|
||||||
rejected_reason=rejected_reason,
|
rejected_reason=rejected_reason,
|
||||||
)
|
)
|
||||||
original_ev.internal_metadata.stream_ordering = row.stream_ordering
|
original_ev.internal_metadata.stream_ordering = row.stream_ordering
|
||||||
|
original_ev.internal_metadata.instance_name = row.instance_name
|
||||||
original_ev.internal_metadata.outlier = row.outlier
|
original_ev.internal_metadata.outlier = row.outlier
|
||||||
|
|
||||||
# Consistency check: if the content of the event has been modified in the
|
# Consistency check: if the content of the event has been modified in the
|
||||||
|
@ -1439,6 +1441,7 @@ class EventsWorkerStore(SQLBaseStore):
|
||||||
SELECT
|
SELECT
|
||||||
e.event_id,
|
e.event_id,
|
||||||
e.stream_ordering,
|
e.stream_ordering,
|
||||||
|
e.instance_name,
|
||||||
ej.internal_metadata,
|
ej.internal_metadata,
|
||||||
ej.json,
|
ej.json,
|
||||||
ej.format_version,
|
ej.format_version,
|
||||||
|
@ -1462,13 +1465,14 @@ class EventsWorkerStore(SQLBaseStore):
|
||||||
event_dict[event_id] = _EventRow(
|
event_dict[event_id] = _EventRow(
|
||||||
event_id=event_id,
|
event_id=event_id,
|
||||||
stream_ordering=row[1],
|
stream_ordering=row[1],
|
||||||
internal_metadata=row[2],
|
instance_name=row[2],
|
||||||
json=row[3],
|
internal_metadata=row[3],
|
||||||
format_version=row[4],
|
json=row[4],
|
||||||
room_version_id=row[5],
|
format_version=row[5],
|
||||||
rejected_reason=row[6],
|
room_version_id=row[6],
|
||||||
|
rejected_reason=row[7],
|
||||||
redactions=[],
|
redactions=[],
|
||||||
outlier=bool(row[7]), # This is an int in SQLite3
|
outlier=bool(row[8]), # This is an int in SQLite3
|
||||||
)
|
)
|
||||||
|
|
||||||
# check for redactions
|
# check for redactions
|
||||||
|
|
|
@ -914,12 +914,23 @@ class StreamWorkerStore(EventsWorkerStore, SQLBaseStore):
|
||||||
def get_last_event_in_room_before_stream_ordering_txn(
|
def get_last_event_in_room_before_stream_ordering_txn(
|
||||||
txn: LoggingTransaction,
|
txn: LoggingTransaction,
|
||||||
) -> Optional[str]:
|
) -> Optional[str]:
|
||||||
# We need to handle the fact that the stream tokens can be vector
|
# We're looking for the closest event at or before the token. We need to
|
||||||
# clocks. We do this by getting all rows between the minimum and
|
# handle the fact that the stream token can be a vector clock (with an
|
||||||
# maximum stream ordering in the token, plus one row less than the
|
# `instance_map`) and events can be persisted on different instances
|
||||||
# minimum stream ordering. We then filter the results against the
|
# (sharded event persisters). The first subquery handles the events that
|
||||||
# token and return the first row that matches.
|
# would be within the vector clock and gets all rows between the minimum and
|
||||||
|
# maximum stream ordering in the token which need to be filtered against the
|
||||||
|
# `instance_map`. The second subquery handles the "before" case and finds
|
||||||
|
# the first row before the token. We then filter out any results past the
|
||||||
|
# token's vector clock and return the first row that matches.
|
||||||
|
min_stream = end_token.stream
|
||||||
|
max_stream = end_token.get_max_stream_pos()
|
||||||
|
|
||||||
|
# We use `union all` because we don't need any of the deduplication logic
|
||||||
|
# (`union` is really a union + distinct). `UNION ALL` does preserve the
|
||||||
|
# ordering of the operand queries but there is no actual gurantee that it
|
||||||
|
# has this behavior in all scenarios so we need the extra `ORDER BY` at the
|
||||||
|
# bottom.
|
||||||
sql = """
|
sql = """
|
||||||
SELECT * FROM (
|
SELECT * FROM (
|
||||||
SELECT instance_name, stream_ordering, topological_ordering, event_id
|
SELECT instance_name, stream_ordering, topological_ordering, event_id
|
||||||
|
@ -931,7 +942,7 @@ class StreamWorkerStore(EventsWorkerStore, SQLBaseStore):
|
||||||
AND rejections.event_id IS NULL
|
AND rejections.event_id IS NULL
|
||||||
ORDER BY stream_ordering DESC
|
ORDER BY stream_ordering DESC
|
||||||
) AS a
|
) AS a
|
||||||
UNION
|
UNION ALL
|
||||||
SELECT * FROM (
|
SELECT * FROM (
|
||||||
SELECT instance_name, stream_ordering, topological_ordering, event_id
|
SELECT instance_name, stream_ordering, topological_ordering, event_id
|
||||||
FROM events
|
FROM events
|
||||||
|
@ -943,15 +954,16 @@ class StreamWorkerStore(EventsWorkerStore, SQLBaseStore):
|
||||||
ORDER BY stream_ordering DESC
|
ORDER BY stream_ordering DESC
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
) AS b
|
) AS b
|
||||||
|
ORDER BY stream_ordering DESC
|
||||||
"""
|
"""
|
||||||
txn.execute(
|
txn.execute(
|
||||||
sql,
|
sql,
|
||||||
(
|
(
|
||||||
room_id,
|
room_id,
|
||||||
end_token.stream,
|
min_stream,
|
||||||
end_token.get_max_stream_pos(),
|
max_stream,
|
||||||
room_id,
|
room_id,
|
||||||
end_token.stream,
|
min_stream,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
@ -19,6 +19,8 @@ class EventInternalMetadata:
|
||||||
|
|
||||||
stream_ordering: Optional[int]
|
stream_ordering: Optional[int]
|
||||||
"""the stream ordering of this event. None, until it has been persisted."""
|
"""the stream ordering of this event. None, until it has been persisted."""
|
||||||
|
instance_name: Optional[str]
|
||||||
|
"""the instance name of the server that persisted this event. None, until it has been persisted."""
|
||||||
|
|
||||||
outlier: bool
|
outlier: bool
|
||||||
"""whether this event is an outlier (ie, whether we have the state at that
|
"""whether this event is an outlier (ie, whether we have the state at that
|
||||||
|
|
|
@ -238,6 +238,53 @@ class SlidingSyncBody(RequestBodyModel):
|
||||||
"""
|
"""
|
||||||
|
|
||||||
class Filters(RequestBodyModel):
|
class Filters(RequestBodyModel):
|
||||||
|
"""
|
||||||
|
All fields are applied with AND operators, hence if `is_dm: True` and
|
||||||
|
`is_encrypted: True` then only Encrypted DM rooms will be returned. The
|
||||||
|
absence of fields implies no filter on that criteria: it does NOT imply
|
||||||
|
`False`. These fields may be expanded through use of extensions.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
is_dm: Flag which only returns rooms present (or not) in the DM section
|
||||||
|
of account data. If unset, both DM rooms and non-DM rooms are returned.
|
||||||
|
If False, only non-DM rooms are returned. If True, only DM rooms are
|
||||||
|
returned.
|
||||||
|
spaces: Filter the room based on the space they belong to according to
|
||||||
|
`m.space.child` state events. If multiple spaces are present, a room can
|
||||||
|
be part of any one of the listed spaces (OR'd). The server will inspect
|
||||||
|
the `m.space.child` state events for the JOINED space room IDs given.
|
||||||
|
Servers MUST NOT navigate subspaces. It is up to the client to give a
|
||||||
|
complete list of spaces to navigate. Only rooms directly mentioned as
|
||||||
|
`m.space.child` events in these spaces will be returned. Unknown spaces
|
||||||
|
or spaces the user is not joined to will be ignored.
|
||||||
|
is_encrypted: Flag which only returns rooms which have an
|
||||||
|
`m.room.encryption` state event. If unset, both encrypted and
|
||||||
|
unencrypted rooms are returned. If `False`, only unencrypted rooms are
|
||||||
|
returned. If `True`, only encrypted rooms are returned.
|
||||||
|
is_invite: Flag which only returns rooms the user is currently invited
|
||||||
|
to. If unset, both invited and joined rooms are returned. If `False`, no
|
||||||
|
invited rooms are returned. If `True`, only invited rooms are returned.
|
||||||
|
room_types: If specified, only rooms where the `m.room.create` event has
|
||||||
|
a `type` matching one of the strings in this array will be returned. If
|
||||||
|
this field is unset, all rooms are returned regardless of type. This can
|
||||||
|
be used to get the initial set of spaces for an account. For rooms which
|
||||||
|
do not have a room type, use `null`/`None` to include them.
|
||||||
|
not_room_types: Same as `room_types` but inverted. This can be used to
|
||||||
|
filter out spaces from the room list. If a type is in both `room_types`
|
||||||
|
and `not_room_types`, then `not_room_types` wins and they are not included
|
||||||
|
in the result.
|
||||||
|
room_name_like: Filter the room name. Case-insensitive partial matching
|
||||||
|
e.g 'foo' matches 'abFooab'. The term 'like' is inspired by SQL 'LIKE',
|
||||||
|
and the text here is similar to '%foo%'.
|
||||||
|
tags: Filter the room based on its room tags. If multiple tags are
|
||||||
|
present, a room can have any one of the listed tags (OR'd).
|
||||||
|
not_tags: Filter the room based on its room tags. Takes priority over
|
||||||
|
`tags`. For example, a room with tags A and B with filters `tags: [A]`
|
||||||
|
`not_tags: [B]` would NOT be included because `not_tags` takes priority over
|
||||||
|
`tags`. This filter is useful if your rooms list does NOT include the
|
||||||
|
list of favourite rooms again.
|
||||||
|
"""
|
||||||
|
|
||||||
is_dm: Optional[StrictBool] = None
|
is_dm: Optional[StrictBool] = None
|
||||||
spaces: Optional[List[StrictStr]] = None
|
spaces: Optional[List[StrictStr]] = None
|
||||||
is_encrypted: Optional[StrictBool] = None
|
is_encrypted: Optional[StrictBool] = None
|
||||||
|
|
|
@ -82,7 +82,6 @@ async def filter_events_for_client(
|
||||||
is_peeking: bool = False,
|
is_peeking: bool = False,
|
||||||
always_include_ids: FrozenSet[str] = frozenset(),
|
always_include_ids: FrozenSet[str] = frozenset(),
|
||||||
filter_send_to_client: bool = True,
|
filter_send_to_client: bool = True,
|
||||||
msc4115_membership_on_events: bool = False,
|
|
||||||
) -> List[EventBase]:
|
) -> List[EventBase]:
|
||||||
"""
|
"""
|
||||||
Check which events a user is allowed to see. If the user can see the event but its
|
Check which events a user is allowed to see. If the user can see the event but its
|
||||||
|
@ -101,12 +100,10 @@ async def filter_events_for_client(
|
||||||
filter_send_to_client: Whether we're checking an event that's going to be
|
filter_send_to_client: Whether we're checking an event that's going to be
|
||||||
sent to a client. This might not always be the case since this function can
|
sent to a client. This might not always be the case since this function can
|
||||||
also be called to check whether a user can see the state at a given point.
|
also be called to check whether a user can see the state at a given point.
|
||||||
msc4115_membership_on_events: Whether to include the requesting user's
|
|
||||||
membership in the "unsigned" data, per MSC4115.
|
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
The filtered events. If `msc4115_membership_on_events` is true, the `unsigned`
|
The filtered events. The `unsigned` data is annotated with the membership state
|
||||||
data is annotated with the membership state of `user_id` at each event.
|
of `user_id` at each event.
|
||||||
"""
|
"""
|
||||||
# Filter out events that have been soft failed so that we don't relay them
|
# Filter out events that have been soft failed so that we don't relay them
|
||||||
# to clients.
|
# to clients.
|
||||||
|
@ -159,9 +156,6 @@ async def filter_events_for_client(
|
||||||
if filtered is None:
|
if filtered is None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
if not msc4115_membership_on_events:
|
|
||||||
return filtered
|
|
||||||
|
|
||||||
# Annotate the event with the user's membership after the event.
|
# Annotate the event with the user's membership after the event.
|
||||||
#
|
#
|
||||||
# Normally we just look in `state_after_event`, but if the event is an outlier
|
# Normally we just look in `state_after_event`, but if the event is an outlier
|
||||||
|
@ -186,7 +180,7 @@ async def filter_events_for_client(
|
||||||
# Copy the event before updating the unsigned data: this shouldn't be persisted
|
# Copy the event before updating the unsigned data: this shouldn't be persisted
|
||||||
# to the cache!
|
# to the cache!
|
||||||
cloned = clone_event(filtered)
|
cloned = clone_event(filtered)
|
||||||
cloned.unsigned[EventUnsignedContentFields.MSC4115_MEMBERSHIP] = user_membership
|
cloned.unsigned[EventUnsignedContentFields.MEMBERSHIP] = user_membership
|
||||||
|
|
||||||
return cloned
|
return cloned
|
||||||
|
|
||||||
|
|
|
@ -625,6 +625,8 @@ class CloneEventTestCase(stdlib_unittest.TestCase):
|
||||||
)
|
)
|
||||||
original.internal_metadata.stream_ordering = 1234
|
original.internal_metadata.stream_ordering = 1234
|
||||||
self.assertEqual(original.internal_metadata.stream_ordering, 1234)
|
self.assertEqual(original.internal_metadata.stream_ordering, 1234)
|
||||||
|
original.internal_metadata.instance_name = "worker1"
|
||||||
|
self.assertEqual(original.internal_metadata.instance_name, "worker1")
|
||||||
|
|
||||||
cloned = clone_event(original)
|
cloned = clone_event(original)
|
||||||
cloned.unsigned["b"] = 3
|
cloned.unsigned["b"] = 3
|
||||||
|
@ -632,6 +634,7 @@ class CloneEventTestCase(stdlib_unittest.TestCase):
|
||||||
self.assertEqual(original.unsigned, {"a": 1, "b": 2})
|
self.assertEqual(original.unsigned, {"a": 1, "b": 2})
|
||||||
self.assertEqual(cloned.unsigned, {"a": 1, "b": 3})
|
self.assertEqual(cloned.unsigned, {"a": 1, "b": 3})
|
||||||
self.assertEqual(cloned.internal_metadata.stream_ordering, 1234)
|
self.assertEqual(cloned.internal_metadata.stream_ordering, 1234)
|
||||||
|
self.assertEqual(cloned.internal_metadata.instance_name, "worker1")
|
||||||
self.assertEqual(cloned.internal_metadata.txn_id, "txn")
|
self.assertEqual(cloned.internal_metadata.txn_id, "txn")
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -22,8 +22,9 @@ from unittest.mock import patch
|
||||||
|
|
||||||
from twisted.test.proto_helpers import MemoryReactor
|
from twisted.test.proto_helpers import MemoryReactor
|
||||||
|
|
||||||
from synapse.api.constants import EventTypes, JoinRules, Membership
|
from synapse.api.constants import AccountDataTypes, EventTypes, JoinRules, Membership
|
||||||
from synapse.api.room_versions import RoomVersions
|
from synapse.api.room_versions import RoomVersions
|
||||||
|
from synapse.handlers.sliding_sync import SlidingSyncConfig
|
||||||
from synapse.rest import admin
|
from synapse.rest import admin
|
||||||
from synapse.rest.client import knock, login, room
|
from synapse.rest.client import knock, login, room
|
||||||
from synapse.server import HomeServer
|
from synapse.server import HomeServer
|
||||||
|
@ -326,7 +327,7 @@ class GetSyncRoomIdsForUserTestCase(HomeserverTestCase):
|
||||||
|
|
||||||
# Leave during the from_token/to_token range (newly_left)
|
# Leave during the from_token/to_token range (newly_left)
|
||||||
room_id2 = self.helper.create_room_as(user1_id, tok=user1_tok)
|
room_id2 = self.helper.create_room_as(user1_id, tok=user1_tok)
|
||||||
self.helper.leave(room_id1, user1_id, tok=user1_tok)
|
self.helper.leave(room_id2, user1_id, tok=user1_tok)
|
||||||
|
|
||||||
after_room2_token = self.event_sources.get_current_token()
|
after_room2_token = self.event_sources.get_current_token()
|
||||||
|
|
||||||
|
@ -1116,3 +1117,130 @@ class GetSyncRoomIdsForUserEventShardTestCase(BaseMultiWorkerStreamTestCase):
|
||||||
room_id3,
|
room_id3,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class FilterRoomsTestCase(HomeserverTestCase):
|
||||||
|
"""
|
||||||
|
Tests Sliding Sync handler `filter_rooms()` to make sure it includes/excludes rooms
|
||||||
|
correctly.
|
||||||
|
"""
|
||||||
|
|
||||||
|
servlets = [
|
||||||
|
admin.register_servlets,
|
||||||
|
knock.register_servlets,
|
||||||
|
login.register_servlets,
|
||||||
|
room.register_servlets,
|
||||||
|
]
|
||||||
|
|
||||||
|
def default_config(self) -> JsonDict:
|
||||||
|
config = super().default_config()
|
||||||
|
# Enable sliding sync
|
||||||
|
config["experimental_features"] = {"msc3575_enabled": True}
|
||||||
|
return config
|
||||||
|
|
||||||
|
def prepare(self, reactor: MemoryReactor, clock: Clock, hs: HomeServer) -> None:
|
||||||
|
self.sliding_sync_handler = self.hs.get_sliding_sync_handler()
|
||||||
|
self.store = self.hs.get_datastores().main
|
||||||
|
self.event_sources = hs.get_event_sources()
|
||||||
|
|
||||||
|
def _create_dm_room(
|
||||||
|
self,
|
||||||
|
inviter_user_id: str,
|
||||||
|
inviter_tok: str,
|
||||||
|
invitee_user_id: str,
|
||||||
|
invitee_tok: str,
|
||||||
|
) -> str:
|
||||||
|
"""
|
||||||
|
Helper to create a DM room as the "inviter" and invite the "invitee" user to the room. The
|
||||||
|
"invitee" user also will join the room. The `m.direct` account data will be set
|
||||||
|
for both users.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Create a room and send an invite the other user
|
||||||
|
room_id = self.helper.create_room_as(
|
||||||
|
inviter_user_id,
|
||||||
|
is_public=False,
|
||||||
|
tok=inviter_tok,
|
||||||
|
)
|
||||||
|
self.helper.invite(
|
||||||
|
room_id,
|
||||||
|
src=inviter_user_id,
|
||||||
|
targ=invitee_user_id,
|
||||||
|
tok=inviter_tok,
|
||||||
|
extra_data={"is_direct": True},
|
||||||
|
)
|
||||||
|
# Person that was invited joins the room
|
||||||
|
self.helper.join(room_id, invitee_user_id, tok=invitee_tok)
|
||||||
|
|
||||||
|
# Mimic the client setting the room as a direct message in the global account
|
||||||
|
# data
|
||||||
|
self.get_success(
|
||||||
|
self.store.add_account_data_for_user(
|
||||||
|
invitee_user_id,
|
||||||
|
AccountDataTypes.DIRECT,
|
||||||
|
{inviter_user_id: [room_id]},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.get_success(
|
||||||
|
self.store.add_account_data_for_user(
|
||||||
|
inviter_user_id,
|
||||||
|
AccountDataTypes.DIRECT,
|
||||||
|
{invitee_user_id: [room_id]},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return room_id
|
||||||
|
|
||||||
|
def test_filter_dm_rooms(self) -> None:
|
||||||
|
"""
|
||||||
|
Test `filter.is_dm` for DM rooms
|
||||||
|
"""
|
||||||
|
user1_id = self.register_user("user1", "pass")
|
||||||
|
user1_tok = self.login(user1_id, "pass")
|
||||||
|
user2_id = self.register_user("user2", "pass")
|
||||||
|
user2_tok = self.login(user2_id, "pass")
|
||||||
|
|
||||||
|
# Create a normal room
|
||||||
|
room_id = self.helper.create_room_as(
|
||||||
|
user1_id,
|
||||||
|
is_public=False,
|
||||||
|
tok=user1_tok,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create a DM room
|
||||||
|
dm_room_id = self._create_dm_room(
|
||||||
|
inviter_user_id=user1_id,
|
||||||
|
inviter_tok=user1_tok,
|
||||||
|
invitee_user_id=user2_id,
|
||||||
|
invitee_tok=user2_tok,
|
||||||
|
)
|
||||||
|
|
||||||
|
after_rooms_token = self.event_sources.get_current_token()
|
||||||
|
|
||||||
|
# Try with `is_dm=True`
|
||||||
|
truthy_filtered_room_ids = self.get_success(
|
||||||
|
self.sliding_sync_handler.filter_rooms(
|
||||||
|
UserID.from_string(user1_id),
|
||||||
|
{room_id, dm_room_id},
|
||||||
|
SlidingSyncConfig.SlidingSyncList.Filters(
|
||||||
|
is_dm=True,
|
||||||
|
),
|
||||||
|
after_rooms_token,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(truthy_filtered_room_ids, {dm_room_id})
|
||||||
|
|
||||||
|
# Try with `is_dm=False`
|
||||||
|
falsy_filtered_room_ids = self.get_success(
|
||||||
|
self.sliding_sync_handler.filter_rooms(
|
||||||
|
UserID.from_string(user1_id),
|
||||||
|
{room_id, dm_room_id},
|
||||||
|
SlidingSyncConfig.SlidingSyncList.Filters(
|
||||||
|
is_dm=False,
|
||||||
|
),
|
||||||
|
after_rooms_token,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(falsy_filtered_room_ids, {room_id})
|
||||||
|
|
|
@ -205,8 +205,24 @@ class EmailPusherTests(HomeserverTestCase):
|
||||||
|
|
||||||
# Multipart: plain text, base 64 encoded; html, base 64 encoded
|
# Multipart: plain text, base 64 encoded; html, base 64 encoded
|
||||||
multipart_msg = email.message_from_bytes(msg)
|
multipart_msg = email.message_from_bytes(msg)
|
||||||
txt = multipart_msg.get_payload()[0].get_payload(decode=True).decode()
|
|
||||||
html = multipart_msg.get_payload()[1].get_payload(decode=True).decode()
|
# Extract the text (non-HTML) portion of the multipart Message,
|
||||||
|
# as a Message.
|
||||||
|
txt_message = multipart_msg.get_payload(i=0)
|
||||||
|
assert isinstance(txt_message, email.message.Message)
|
||||||
|
|
||||||
|
# Extract the actual bytes from the Message object, and decode them to a `str`.
|
||||||
|
txt_bytes = txt_message.get_payload(decode=True)
|
||||||
|
assert isinstance(txt_bytes, bytes)
|
||||||
|
txt = txt_bytes.decode()
|
||||||
|
|
||||||
|
# Do the same for the HTML portion of the multipart Message.
|
||||||
|
html_message = multipart_msg.get_payload(i=1)
|
||||||
|
assert isinstance(html_message, email.message.Message)
|
||||||
|
html_bytes = html_message.get_payload(decode=True)
|
||||||
|
assert isinstance(html_bytes, bytes)
|
||||||
|
html = html_bytes.decode()
|
||||||
|
|
||||||
self.assertIn("/_synapse/client/unsubscribe", txt)
|
self.assertIn("/_synapse/client/unsubscribe", txt)
|
||||||
self.assertIn("/_synapse/client/unsubscribe", html)
|
self.assertIn("/_synapse/client/unsubscribe", html)
|
||||||
|
|
||||||
|
@ -347,12 +363,17 @@ class EmailPusherTests(HomeserverTestCase):
|
||||||
# That email should contain the room's avatar
|
# That email should contain the room's avatar
|
||||||
msg: bytes = args[5]
|
msg: bytes = args[5]
|
||||||
# Multipart: plain text, base 64 encoded; html, base 64 encoded
|
# Multipart: plain text, base 64 encoded; html, base 64 encoded
|
||||||
html = (
|
|
||||||
email.message_from_bytes(msg)
|
# Extract the html Message object from the Multipart Message.
|
||||||
.get_payload()[1]
|
# We need the asserts to convince mypy that this is OK.
|
||||||
.get_payload(decode=True)
|
html_message = email.message_from_bytes(msg).get_payload(i=1)
|
||||||
.decode()
|
assert isinstance(html_message, email.message.Message)
|
||||||
)
|
|
||||||
|
# Extract the `bytes` from the html Message object, and decode to a `str`.
|
||||||
|
html = html_message.get_payload(decode=True)
|
||||||
|
assert isinstance(html, bytes)
|
||||||
|
html = html.decode()
|
||||||
|
|
||||||
self.assertIn("_matrix/media/v1/thumbnail/DUMMY_MEDIA_ID", html)
|
self.assertIn("_matrix/media/v1/thumbnail/DUMMY_MEDIA_ID", html)
|
||||||
|
|
||||||
def test_empty_room(self) -> None:
|
def test_empty_room(self) -> None:
|
||||||
|
|
|
@ -141,6 +141,7 @@ class EventsWorkerStoreTestCase(BaseWorkerStoreTestCase):
|
||||||
self.persist(type="m.room.create", key="", creator=USER_ID)
|
self.persist(type="m.room.create", key="", creator=USER_ID)
|
||||||
self.check("get_invited_rooms_for_local_user", [USER_ID_2], [])
|
self.check("get_invited_rooms_for_local_user", [USER_ID_2], [])
|
||||||
event = self.persist(type="m.room.member", key=USER_ID_2, membership="invite")
|
event = self.persist(type="m.room.member", key=USER_ID_2, membership="invite")
|
||||||
|
assert event.internal_metadata.instance_name is not None
|
||||||
assert event.internal_metadata.stream_ordering is not None
|
assert event.internal_metadata.stream_ordering is not None
|
||||||
|
|
||||||
self.replicate()
|
self.replicate()
|
||||||
|
@ -155,7 +156,7 @@ class EventsWorkerStoreTestCase(BaseWorkerStoreTestCase):
|
||||||
"invite",
|
"invite",
|
||||||
event.event_id,
|
event.event_id,
|
||||||
PersistedEventPosition(
|
PersistedEventPosition(
|
||||||
self.hs.get_instance_name(),
|
event.internal_metadata.instance_name,
|
||||||
event.internal_metadata.stream_ordering,
|
event.internal_metadata.stream_ordering,
|
||||||
),
|
),
|
||||||
RoomVersions.V1.identifier,
|
RoomVersions.V1.identifier,
|
||||||
|
@ -232,11 +233,12 @@ class EventsWorkerStoreTestCase(BaseWorkerStoreTestCase):
|
||||||
j2 = self.persist(
|
j2 = self.persist(
|
||||||
type="m.room.member", sender=USER_ID_2, key=USER_ID_2, membership="join"
|
type="m.room.member", sender=USER_ID_2, key=USER_ID_2, membership="join"
|
||||||
)
|
)
|
||||||
|
assert j2.internal_metadata.instance_name is not None
|
||||||
assert j2.internal_metadata.stream_ordering is not None
|
assert j2.internal_metadata.stream_ordering is not None
|
||||||
self.replicate()
|
self.replicate()
|
||||||
|
|
||||||
expected_pos = PersistedEventPosition(
|
expected_pos = PersistedEventPosition(
|
||||||
"master", j2.internal_metadata.stream_ordering
|
j2.internal_metadata.instance_name, j2.internal_metadata.stream_ordering
|
||||||
)
|
)
|
||||||
self.check(
|
self.check(
|
||||||
"get_rooms_for_user_with_stream_ordering",
|
"get_rooms_for_user_with_stream_ordering",
|
||||||
|
@ -288,6 +290,7 @@ class EventsWorkerStoreTestCase(BaseWorkerStoreTestCase):
|
||||||
msg, msgctx = self.build_event()
|
msg, msgctx = self.build_event()
|
||||||
self.get_success(self.persistance.persist_events([(j2, j2ctx), (msg, msgctx)]))
|
self.get_success(self.persistance.persist_events([(j2, j2ctx), (msg, msgctx)]))
|
||||||
self.replicate()
|
self.replicate()
|
||||||
|
assert j2.internal_metadata.instance_name is not None
|
||||||
assert j2.internal_metadata.stream_ordering is not None
|
assert j2.internal_metadata.stream_ordering is not None
|
||||||
|
|
||||||
event_source = RoomEventSource(self.hs)
|
event_source = RoomEventSource(self.hs)
|
||||||
|
@ -329,7 +332,8 @@ class EventsWorkerStoreTestCase(BaseWorkerStoreTestCase):
|
||||||
# joined_rooms list.
|
# joined_rooms list.
|
||||||
if membership_changes:
|
if membership_changes:
|
||||||
expected_pos = PersistedEventPosition(
|
expected_pos = PersistedEventPosition(
|
||||||
"master", j2.internal_metadata.stream_ordering
|
j2.internal_metadata.instance_name,
|
||||||
|
j2.internal_metadata.stream_ordering,
|
||||||
)
|
)
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
joined_rooms,
|
joined_rooms,
|
||||||
|
|
|
@ -427,13 +427,23 @@ class PasswordResetTestCase(unittest.HomeserverTestCase):
|
||||||
text = None
|
text = None
|
||||||
for part in mail.walk():
|
for part in mail.walk():
|
||||||
if part.get_content_type() == "text/plain":
|
if part.get_content_type() == "text/plain":
|
||||||
text = part.get_payload(decode=True).decode("UTF-8")
|
text = part.get_payload(decode=True)
|
||||||
|
if text is not None:
|
||||||
|
# According to the logic table in `get_payload`, we know that
|
||||||
|
# the result of `get_payload` will be `bytes`, but mypy doesn't
|
||||||
|
# know this and complains. Thus, we assert the type.
|
||||||
|
assert isinstance(text, bytes)
|
||||||
|
text = text.decode("UTF-8")
|
||||||
|
|
||||||
break
|
break
|
||||||
|
|
||||||
if not text:
|
if not text:
|
||||||
self.fail("Could not find text portion of email to parse")
|
self.fail("Could not find text portion of email to parse")
|
||||||
|
|
||||||
assert text is not None
|
# `text` must be a `str`, after being decoded and determined just above
|
||||||
|
# to not be `None` or an empty `str`.
|
||||||
|
assert isinstance(text, str)
|
||||||
|
|
||||||
match = re.search(r"https://example.com\S+", text)
|
match = re.search(r"https://example.com\S+", text)
|
||||||
assert match, "Could not find link in email"
|
assert match, "Could not find link in email"
|
||||||
|
|
||||||
|
@ -1209,13 +1219,23 @@ class ThreepidEmailRestTestCase(unittest.HomeserverTestCase):
|
||||||
text = None
|
text = None
|
||||||
for part in mail.walk():
|
for part in mail.walk():
|
||||||
if part.get_content_type() == "text/plain":
|
if part.get_content_type() == "text/plain":
|
||||||
text = part.get_payload(decode=True).decode("UTF-8")
|
text = part.get_payload(decode=True)
|
||||||
|
if text is not None:
|
||||||
|
# According to the logic table in `get_payload`, we know that
|
||||||
|
# the result of `get_payload` will be `bytes`, but mypy doesn't
|
||||||
|
# know this and complains. Thus, we assert the type.
|
||||||
|
assert isinstance(text, bytes)
|
||||||
|
text = text.decode("UTF-8")
|
||||||
|
|
||||||
break
|
break
|
||||||
|
|
||||||
if not text:
|
if not text:
|
||||||
self.fail("Could not find text portion of email to parse")
|
self.fail("Could not find text portion of email to parse")
|
||||||
|
|
||||||
assert text is not None
|
# `text` must be a `str`, after being decoded and determined just above
|
||||||
|
# to not be `None` or an empty `str`.
|
||||||
|
assert isinstance(text, str)
|
||||||
|
|
||||||
match = re.search(r"https://example.com\S+", text)
|
match = re.search(r"https://example.com\S+", text)
|
||||||
assert match, "Could not find link in email"
|
assert match, "Could not find link in email"
|
||||||
|
|
||||||
|
|
|
@ -167,7 +167,6 @@ class RetentionTestCase(unittest.HomeserverTestCase):
|
||||||
storage_controllers,
|
storage_controllers,
|
||||||
self.user_id,
|
self.user_id,
|
||||||
events,
|
events,
|
||||||
msc4115_membership_on_events=True,
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
@ -27,6 +27,7 @@ from twisted.test.proto_helpers import MemoryReactor
|
||||||
|
|
||||||
import synapse.rest.admin
|
import synapse.rest.admin
|
||||||
from synapse.api.constants import (
|
from synapse.api.constants import (
|
||||||
|
AccountDataTypes,
|
||||||
EventContentFields,
|
EventContentFields,
|
||||||
EventTypes,
|
EventTypes,
|
||||||
ReceiptTypes,
|
ReceiptTypes,
|
||||||
|
@ -1226,10 +1227,59 @@ class SlidingSyncTestCase(unittest.HomeserverTestCase):
|
||||||
return config
|
return config
|
||||||
|
|
||||||
def prepare(self, reactor: MemoryReactor, clock: Clock, hs: HomeServer) -> None:
|
def prepare(self, reactor: MemoryReactor, clock: Clock, hs: HomeServer) -> None:
|
||||||
|
self.store = hs.get_datastores().main
|
||||||
self.sync_endpoint = "/_matrix/client/unstable/org.matrix.msc3575/sync"
|
self.sync_endpoint = "/_matrix/client/unstable/org.matrix.msc3575/sync"
|
||||||
self.store = hs.get_datastores().main
|
self.store = hs.get_datastores().main
|
||||||
self.event_sources = hs.get_event_sources()
|
self.event_sources = hs.get_event_sources()
|
||||||
|
|
||||||
|
def _create_dm_room(
|
||||||
|
self,
|
||||||
|
inviter_user_id: str,
|
||||||
|
inviter_tok: str,
|
||||||
|
invitee_user_id: str,
|
||||||
|
invitee_tok: str,
|
||||||
|
) -> str:
|
||||||
|
"""
|
||||||
|
Helper to create a DM room as the "inviter" and invite the "invitee" user to the
|
||||||
|
room. The "invitee" user also will join the room. The `m.direct` account data
|
||||||
|
will be set for both users.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Create a room and send an invite the other user
|
||||||
|
room_id = self.helper.create_room_as(
|
||||||
|
inviter_user_id,
|
||||||
|
is_public=False,
|
||||||
|
tok=inviter_tok,
|
||||||
|
)
|
||||||
|
self.helper.invite(
|
||||||
|
room_id,
|
||||||
|
src=inviter_user_id,
|
||||||
|
targ=invitee_user_id,
|
||||||
|
tok=inviter_tok,
|
||||||
|
extra_data={"is_direct": True},
|
||||||
|
)
|
||||||
|
# Person that was invited joins the room
|
||||||
|
self.helper.join(room_id, invitee_user_id, tok=invitee_tok)
|
||||||
|
|
||||||
|
# Mimic the client setting the room as a direct message in the global account
|
||||||
|
# data
|
||||||
|
self.get_success(
|
||||||
|
self.store.add_account_data_for_user(
|
||||||
|
invitee_user_id,
|
||||||
|
AccountDataTypes.DIRECT,
|
||||||
|
{inviter_user_id: [room_id]},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.get_success(
|
||||||
|
self.store.add_account_data_for_user(
|
||||||
|
inviter_user_id,
|
||||||
|
AccountDataTypes.DIRECT,
|
||||||
|
{invitee_user_id: [room_id]},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return room_id
|
||||||
|
|
||||||
def test_sync_list(self) -> None:
|
def test_sync_list(self) -> None:
|
||||||
"""
|
"""
|
||||||
Test that room IDs show up in the Sliding Sync lists
|
Test that room IDs show up in the Sliding Sync lists
|
||||||
|
@ -1336,3 +1386,80 @@ class SlidingSyncTestCase(unittest.HomeserverTestCase):
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
channel.json_body["next_pos"], future_position_token_serialized
|
channel.json_body["next_pos"], future_position_token_serialized
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def test_filter_list(self) -> None:
|
||||||
|
"""
|
||||||
|
Test that filters apply to lists
|
||||||
|
"""
|
||||||
|
user1_id = self.register_user("user1", "pass")
|
||||||
|
user1_tok = self.login(user1_id, "pass")
|
||||||
|
user2_id = self.register_user("user2", "pass")
|
||||||
|
user2_tok = self.login(user2_id, "pass")
|
||||||
|
|
||||||
|
# Create a DM room
|
||||||
|
dm_room_id = self._create_dm_room(
|
||||||
|
inviter_user_id=user1_id,
|
||||||
|
inviter_tok=user1_tok,
|
||||||
|
invitee_user_id=user2_id,
|
||||||
|
invitee_tok=user2_tok,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create a normal room
|
||||||
|
room_id = self.helper.create_room_as(user1_id, tok=user1_tok, is_public=True)
|
||||||
|
|
||||||
|
# Make the Sliding Sync request
|
||||||
|
channel = self.make_request(
|
||||||
|
"POST",
|
||||||
|
self.sync_endpoint,
|
||||||
|
{
|
||||||
|
"lists": {
|
||||||
|
"dms": {
|
||||||
|
"ranges": [[0, 99]],
|
||||||
|
"sort": ["by_recency"],
|
||||||
|
"required_state": [],
|
||||||
|
"timeline_limit": 1,
|
||||||
|
"filters": {"is_dm": True},
|
||||||
|
},
|
||||||
|
"foo-list": {
|
||||||
|
"ranges": [[0, 99]],
|
||||||
|
"sort": ["by_recency"],
|
||||||
|
"required_state": [],
|
||||||
|
"timeline_limit": 1,
|
||||||
|
"filters": {"is_dm": False},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
access_token=user1_tok,
|
||||||
|
)
|
||||||
|
self.assertEqual(channel.code, 200, channel.json_body)
|
||||||
|
|
||||||
|
# Make sure it has the foo-list we requested
|
||||||
|
self.assertListEqual(
|
||||||
|
list(channel.json_body["lists"].keys()),
|
||||||
|
["dms", "foo-list"],
|
||||||
|
channel.json_body["lists"].keys(),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Make sure the list includes the room we are joined to
|
||||||
|
self.assertListEqual(
|
||||||
|
list(channel.json_body["lists"]["dms"]["ops"]),
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"op": "SYNC",
|
||||||
|
"range": [0, 99],
|
||||||
|
"room_ids": [dm_room_id],
|
||||||
|
}
|
||||||
|
],
|
||||||
|
list(channel.json_body["lists"]["dms"]),
|
||||||
|
)
|
||||||
|
self.assertListEqual(
|
||||||
|
list(channel.json_body["lists"]["foo-list"]["ops"]),
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"op": "SYNC",
|
||||||
|
"range": [0, 99],
|
||||||
|
"room_ids": [room_id],
|
||||||
|
}
|
||||||
|
],
|
||||||
|
list(channel.json_body["lists"]["foo-list"]),
|
||||||
|
)
|
||||||
|
|
604
tests/rest/test_scim.py
Normal file
604
tests/rest/test_scim.py
Normal file
|
@ -0,0 +1,604 @@
|
||||||
|
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/<user_id> 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_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_negative_count(self) -> None:
|
||||||
|
"""
|
||||||
|
RFC7644 §3.4.2.4
|
||||||
|
A negative value SHALL be interpreted as 0.
|
||||||
|
A value of "0" indicates that no resource results are
|
||||||
|
to be returned except for "totalResults".
|
||||||
|
|
||||||
|
https://datatracker.ietf.org/doc/html/rfc7644#section-3.4.2.4
|
||||||
|
"""
|
||||||
|
channel = self.make_request(
|
||||||
|
"GET",
|
||||||
|
f"{self.url}/Users?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(
|
||||||
|
0,
|
||||||
|
len(channel.json_body["Resources"]),
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
17,
|
||||||
|
channel.json_body["totalResults"],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_get_users_pagination_negative_start_index(self) -> None:
|
||||||
|
"""
|
||||||
|
RFC7644 §3.4.2.4
|
||||||
|
A value less than 1 SHALL be interpreted as 1.
|
||||||
|
|
||||||
|
https://datatracker.ietf.org/doc/html/rfc7644#section-3.4.2.4
|
||||||
|
"""
|
||||||
|
channel = self.make_request(
|
||||||
|
"GET",
|
||||||
|
f"{self.url}/Users?startIndex=-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"]), 17)
|
||||||
|
self.assertEqual(channel.json_body["Resources"][0]["id"], "@admin: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"],
|
||||||
|
)
|
|
@ -431,6 +431,7 @@ class EventChainStoreTestCase(HomeserverTestCase):
|
||||||
|
|
||||||
for e in events:
|
for e in events:
|
||||||
e.internal_metadata.stream_ordering = self._next_stream_ordering
|
e.internal_metadata.stream_ordering = self._next_stream_ordering
|
||||||
|
e.internal_metadata.instance_name = self.hs.get_instance_name()
|
||||||
self._next_stream_ordering += 1
|
self._next_stream_ordering += 1
|
||||||
|
|
||||||
def _persist(txn: LoggingTransaction) -> None:
|
def _persist(txn: LoggingTransaction) -> None:
|
||||||
|
|
|
@ -19,7 +19,10 @@
|
||||||
#
|
#
|
||||||
#
|
#
|
||||||
|
|
||||||
from typing import List
|
import logging
|
||||||
|
from typing import List, Tuple
|
||||||
|
|
||||||
|
from immutabledict import immutabledict
|
||||||
|
|
||||||
from twisted.test.proto_helpers import MemoryReactor
|
from twisted.test.proto_helpers import MemoryReactor
|
||||||
|
|
||||||
|
@ -28,11 +31,13 @@ from synapse.api.filtering import Filter
|
||||||
from synapse.rest import admin
|
from synapse.rest import admin
|
||||||
from synapse.rest.client import login, room
|
from synapse.rest.client import login, room
|
||||||
from synapse.server import HomeServer
|
from synapse.server import HomeServer
|
||||||
from synapse.types import JsonDict
|
from synapse.types import JsonDict, PersistedEventPosition, RoomStreamToken
|
||||||
from synapse.util import Clock
|
from synapse.util import Clock
|
||||||
|
|
||||||
from tests.unittest import HomeserverTestCase
|
from tests.unittest import HomeserverTestCase
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class PaginationTestCase(HomeserverTestCase):
|
class PaginationTestCase(HomeserverTestCase):
|
||||||
"""
|
"""
|
||||||
|
@ -268,3 +273,263 @@ class PaginationTestCase(HomeserverTestCase):
|
||||||
}
|
}
|
||||||
chunk = self._filter_messages(filter)
|
chunk = self._filter_messages(filter)
|
||||||
self.assertEqual(chunk, [self.event_id_1, self.event_id_2, self.event_id_none])
|
self.assertEqual(chunk, [self.event_id_1, self.event_id_2, self.event_id_none])
|
||||||
|
|
||||||
|
|
||||||
|
class GetLastEventInRoomBeforeStreamOrderingTestCase(HomeserverTestCase):
|
||||||
|
"""
|
||||||
|
Test `get_last_event_in_room_before_stream_ordering(...)`
|
||||||
|
"""
|
||||||
|
|
||||||
|
servlets = [
|
||||||
|
admin.register_servlets,
|
||||||
|
room.register_servlets,
|
||||||
|
login.register_servlets,
|
||||||
|
]
|
||||||
|
|
||||||
|
def prepare(self, reactor: MemoryReactor, clock: Clock, hs: HomeServer) -> None:
|
||||||
|
self.store = hs.get_datastores().main
|
||||||
|
self.event_sources = hs.get_event_sources()
|
||||||
|
|
||||||
|
def _update_persisted_instance_name_for_event(
|
||||||
|
self, event_id: str, instance_name: str
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Update the `instance_name` that persisted the the event in the database.
|
||||||
|
"""
|
||||||
|
return self.get_success(
|
||||||
|
self.store.db_pool.simple_update_one(
|
||||||
|
"events",
|
||||||
|
keyvalues={"event_id": event_id},
|
||||||
|
updatevalues={"instance_name": instance_name},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def _send_event_on_instance(
|
||||||
|
self, instance_name: str, room_id: str, access_token: str
|
||||||
|
) -> Tuple[JsonDict, PersistedEventPosition]:
|
||||||
|
"""
|
||||||
|
Send an event in a room and mimic that it was persisted by a specific
|
||||||
|
instance/worker.
|
||||||
|
"""
|
||||||
|
event_response = self.helper.send(
|
||||||
|
room_id, f"{instance_name} message", tok=access_token
|
||||||
|
)
|
||||||
|
|
||||||
|
self._update_persisted_instance_name_for_event(
|
||||||
|
event_response["event_id"], instance_name
|
||||||
|
)
|
||||||
|
|
||||||
|
event_pos = self.get_success(
|
||||||
|
self.store.get_position_for_event(event_response["event_id"])
|
||||||
|
)
|
||||||
|
|
||||||
|
return event_response, event_pos
|
||||||
|
|
||||||
|
def test_before_room_created(self) -> None:
|
||||||
|
"""
|
||||||
|
Test that no event is returned if we are using a token before the room was even created
|
||||||
|
"""
|
||||||
|
user1_id = self.register_user("user1", "pass")
|
||||||
|
user1_tok = self.login(user1_id, "pass")
|
||||||
|
|
||||||
|
before_room_token = self.event_sources.get_current_token()
|
||||||
|
|
||||||
|
room_id = self.helper.create_room_as(user1_id, tok=user1_tok, is_public=True)
|
||||||
|
|
||||||
|
last_event = self.get_success(
|
||||||
|
self.store.get_last_event_in_room_before_stream_ordering(
|
||||||
|
room_id=room_id,
|
||||||
|
end_token=before_room_token.room_key,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertIsNone(last_event)
|
||||||
|
|
||||||
|
def test_after_room_created(self) -> None:
|
||||||
|
"""
|
||||||
|
Test that an event is returned if we are using a token after the room was created
|
||||||
|
"""
|
||||||
|
user1_id = self.register_user("user1", "pass")
|
||||||
|
user1_tok = self.login(user1_id, "pass")
|
||||||
|
|
||||||
|
room_id = self.helper.create_room_as(user1_id, tok=user1_tok, is_public=True)
|
||||||
|
|
||||||
|
after_room_token = self.event_sources.get_current_token()
|
||||||
|
|
||||||
|
last_event = self.get_success(
|
||||||
|
self.store.get_last_event_in_room_before_stream_ordering(
|
||||||
|
room_id=room_id,
|
||||||
|
end_token=after_room_token.room_key,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertIsNotNone(last_event)
|
||||||
|
|
||||||
|
def test_activity_in_other_rooms(self) -> None:
|
||||||
|
"""
|
||||||
|
Test to make sure that the last event in the room is returned even if the
|
||||||
|
`stream_ordering` has advanced from activity in other rooms.
|
||||||
|
"""
|
||||||
|
user1_id = self.register_user("user1", "pass")
|
||||||
|
user1_tok = self.login(user1_id, "pass")
|
||||||
|
|
||||||
|
room_id1 = self.helper.create_room_as(user1_id, tok=user1_tok, is_public=True)
|
||||||
|
event_response = self.helper.send(room_id1, "target!", tok=user1_tok)
|
||||||
|
# Create another room to advance the stream_ordering
|
||||||
|
self.helper.create_room_as(user1_id, tok=user1_tok, is_public=True)
|
||||||
|
|
||||||
|
after_room_token = self.event_sources.get_current_token()
|
||||||
|
|
||||||
|
last_event = self.get_success(
|
||||||
|
self.store.get_last_event_in_room_before_stream_ordering(
|
||||||
|
room_id=room_id1,
|
||||||
|
end_token=after_room_token.room_key,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Make sure it's the event we expect (which also means we know it's from the
|
||||||
|
# correct room)
|
||||||
|
self.assertEqual(last_event, event_response["event_id"])
|
||||||
|
|
||||||
|
def test_activity_after_token_has_no_effect(self) -> None:
|
||||||
|
"""
|
||||||
|
Test to make sure we return the last event before the token even if there is
|
||||||
|
activity after it.
|
||||||
|
"""
|
||||||
|
user1_id = self.register_user("user1", "pass")
|
||||||
|
user1_tok = self.login(user1_id, "pass")
|
||||||
|
|
||||||
|
room_id1 = self.helper.create_room_as(user1_id, tok=user1_tok, is_public=True)
|
||||||
|
event_response = self.helper.send(room_id1, "target!", tok=user1_tok)
|
||||||
|
|
||||||
|
after_room_token = self.event_sources.get_current_token()
|
||||||
|
|
||||||
|
# Send some events after the token
|
||||||
|
self.helper.send(room_id1, "after1", tok=user1_tok)
|
||||||
|
self.helper.send(room_id1, "after2", tok=user1_tok)
|
||||||
|
|
||||||
|
last_event = self.get_success(
|
||||||
|
self.store.get_last_event_in_room_before_stream_ordering(
|
||||||
|
room_id=room_id1,
|
||||||
|
end_token=after_room_token.room_key,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Make sure it's the last event before the token
|
||||||
|
self.assertEqual(last_event, event_response["event_id"])
|
||||||
|
|
||||||
|
def test_last_event_within_sharded_token(self) -> None:
|
||||||
|
"""
|
||||||
|
Test to make sure we can find the last event that that is *within* the sharded
|
||||||
|
token (a token that has an `instance_map` and looks like
|
||||||
|
`m{min_pos}~{writer1}.{pos1}~{writer2}.{pos2}`). We are specifically testing
|
||||||
|
that we can find an event within the tokens minimum and instance
|
||||||
|
`stream_ordering`.
|
||||||
|
"""
|
||||||
|
user1_id = self.register_user("user1", "pass")
|
||||||
|
user1_tok = self.login(user1_id, "pass")
|
||||||
|
|
||||||
|
room_id1 = self.helper.create_room_as(user1_id, tok=user1_tok, is_public=True)
|
||||||
|
event_response1, event_pos1 = self._send_event_on_instance(
|
||||||
|
"worker1", room_id1, user1_tok
|
||||||
|
)
|
||||||
|
event_response2, event_pos2 = self._send_event_on_instance(
|
||||||
|
"worker1", room_id1, user1_tok
|
||||||
|
)
|
||||||
|
event_response3, event_pos3 = self._send_event_on_instance(
|
||||||
|
"worker1", room_id1, user1_tok
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create another room to advance the `stream_ordering` on the same worker
|
||||||
|
# so we can sandwich event3 in the middle of the token
|
||||||
|
room_id2 = self.helper.create_room_as(user1_id, tok=user1_tok, is_public=True)
|
||||||
|
event_response4, event_pos4 = self._send_event_on_instance(
|
||||||
|
"worker1", room_id2, user1_tok
|
||||||
|
)
|
||||||
|
|
||||||
|
# Assemble a token that encompasses event1 -> event4 on worker1
|
||||||
|
end_token = RoomStreamToken(
|
||||||
|
stream=event_pos2.stream,
|
||||||
|
instance_map=immutabledict({"worker1": event_pos4.stream}),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Send some events after the token
|
||||||
|
self.helper.send(room_id1, "after1", tok=user1_tok)
|
||||||
|
self.helper.send(room_id1, "after2", tok=user1_tok)
|
||||||
|
|
||||||
|
last_event = self.get_success(
|
||||||
|
self.store.get_last_event_in_room_before_stream_ordering(
|
||||||
|
room_id=room_id1,
|
||||||
|
end_token=end_token,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Should find closest event at/before the token in room1
|
||||||
|
self.assertEqual(
|
||||||
|
last_event,
|
||||||
|
event_response3["event_id"],
|
||||||
|
f"We expected {event_response3['event_id']} but saw {last_event} which corresponds to "
|
||||||
|
+ str(
|
||||||
|
{
|
||||||
|
"event1": event_response1["event_id"],
|
||||||
|
"event2": event_response2["event_id"],
|
||||||
|
"event3": event_response3["event_id"],
|
||||||
|
}
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_last_event_before_sharded_token(self) -> None:
|
||||||
|
"""
|
||||||
|
Test to make sure we can find the last event that is *before* the sharded token
|
||||||
|
(a token that has an `instance_map` and looks like
|
||||||
|
`m{min_pos}~{writer1}.{pos1}~{writer2}.{pos2}`).
|
||||||
|
"""
|
||||||
|
user1_id = self.register_user("user1", "pass")
|
||||||
|
user1_tok = self.login(user1_id, "pass")
|
||||||
|
|
||||||
|
room_id1 = self.helper.create_room_as(user1_id, tok=user1_tok, is_public=True)
|
||||||
|
event_response1, event_pos1 = self._send_event_on_instance(
|
||||||
|
"worker1", room_id1, user1_tok
|
||||||
|
)
|
||||||
|
event_response2, event_pos2 = self._send_event_on_instance(
|
||||||
|
"worker1", room_id1, user1_tok
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create another room to advance the `stream_ordering` on the same worker
|
||||||
|
room_id2 = self.helper.create_room_as(user1_id, tok=user1_tok, is_public=True)
|
||||||
|
event_response3, event_pos3 = self._send_event_on_instance(
|
||||||
|
"worker1", room_id2, user1_tok
|
||||||
|
)
|
||||||
|
event_response4, event_pos4 = self._send_event_on_instance(
|
||||||
|
"worker1", room_id2, user1_tok
|
||||||
|
)
|
||||||
|
|
||||||
|
# Assemble a token that encompasses event3 -> event4 on worker1
|
||||||
|
end_token = RoomStreamToken(
|
||||||
|
stream=event_pos3.stream,
|
||||||
|
instance_map=immutabledict({"worker1": event_pos4.stream}),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Send some events after the token
|
||||||
|
self.helper.send(room_id1, "after1", tok=user1_tok)
|
||||||
|
self.helper.send(room_id1, "after2", tok=user1_tok)
|
||||||
|
|
||||||
|
last_event = self.get_success(
|
||||||
|
self.store.get_last_event_in_room_before_stream_ordering(
|
||||||
|
room_id=room_id1,
|
||||||
|
end_token=end_token,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Should find closest event at/before the token in room1
|
||||||
|
self.assertEqual(
|
||||||
|
last_event,
|
||||||
|
event_response2["event_id"],
|
||||||
|
f"We expected {event_response2['event_id']} but saw {last_event} which corresponds to "
|
||||||
|
+ str(
|
||||||
|
{
|
||||||
|
"event1": event_response1["event_id"],
|
||||||
|
"event2": event_response2["event_id"],
|
||||||
|
}
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
|
@ -336,7 +336,6 @@ class FilterEventsForClientTestCase(HomeserverTestCase):
|
||||||
self.hs.get_storage_controllers(),
|
self.hs.get_storage_controllers(),
|
||||||
"@joiner:test",
|
"@joiner:test",
|
||||||
events_to_filter,
|
events_to_filter,
|
||||||
msc4115_membership_on_events=True,
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
resident_filtered_events = self.get_success(
|
resident_filtered_events = self.get_success(
|
||||||
|
@ -344,7 +343,6 @@ class FilterEventsForClientTestCase(HomeserverTestCase):
|
||||||
self.hs.get_storage_controllers(),
|
self.hs.get_storage_controllers(),
|
||||||
"@resident:test",
|
"@resident:test",
|
||||||
events_to_filter,
|
events_to_filter,
|
||||||
msc4115_membership_on_events=True,
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -357,7 +355,7 @@ class FilterEventsForClientTestCase(HomeserverTestCase):
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
["join", "join", "leave"],
|
["join", "join", "leave"],
|
||||||
[
|
[
|
||||||
e.unsigned[EventUnsignedContentFields.MSC4115_MEMBERSHIP]
|
e.unsigned[EventUnsignedContentFields.MEMBERSHIP]
|
||||||
for e in joiner_filtered_events
|
for e in joiner_filtered_events
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
@ -379,7 +377,7 @@ class FilterEventsForClientTestCase(HomeserverTestCase):
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
["join", "join", "join", "join", "join"],
|
["join", "join", "join", "join", "join"],
|
||||||
[
|
[
|
||||||
e.unsigned[EventUnsignedContentFields.MSC4115_MEMBERSHIP]
|
e.unsigned[EventUnsignedContentFields.MEMBERSHIP]
|
||||||
for e in resident_filtered_events
|
for e in resident_filtered_events
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
@ -441,7 +439,6 @@ class FilterEventsOutOfBandEventsForClientTestCase(
|
||||||
self.hs.get_storage_controllers(),
|
self.hs.get_storage_controllers(),
|
||||||
"@user:test",
|
"@user:test",
|
||||||
[invite_event, reject_event],
|
[invite_event, reject_event],
|
||||||
msc4115_membership_on_events=True,
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
|
@ -451,7 +448,7 @@ class FilterEventsOutOfBandEventsForClientTestCase(
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
["invite", "leave"],
|
["invite", "leave"],
|
||||||
[
|
[
|
||||||
e.unsigned[EventUnsignedContentFields.MSC4115_MEMBERSHIP]
|
e.unsigned[EventUnsignedContentFields.MEMBERSHIP]
|
||||||
for e in filtered_events
|
for e in filtered_events
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
@ -463,7 +460,6 @@ class FilterEventsOutOfBandEventsForClientTestCase(
|
||||||
self.hs.get_storage_controllers(),
|
self.hs.get_storage_controllers(),
|
||||||
"@other:test",
|
"@other:test",
|
||||||
[invite_event, reject_event],
|
[invite_event, reject_event],
|
||||||
msc4115_membership_on_events=True,
|
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
[],
|
[],
|
||||||
|
|
Loading…
Reference in a new issue