mirror of
https://github.com/element-hq/synapse
synced 2024-07-01 06:03:29 +00:00
Merge 81d751b41c
into 2c36a679ae
This commit is contained in:
commit
f987fb5f10
1
changelog.d/17144.feature
Normal file
1
changelog.d/17144.feature
Normal file
|
@ -0,0 +1 @@
|
||||||
|
Add support for MSC4098 (SCIM provisioning protocol).
|
|
@ -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
|
|
@ -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),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
|
@ -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:
|
||||||
|
|
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": [],
|
||||||
|
}
|
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"],
|
||||||
|
)
|
Loading…
Reference in a new issue