diff --git a/canaille/__init__.py b/canaille/__init__.py index ec1e5c24..3a87d05a 100644 --- a/canaille/__init__.py +++ b/canaille/__init__.py @@ -46,6 +46,11 @@ def setup_blueprints(app): app.register_blueprint(canaille.oidc.endpoints.bp) + if "CANAILLE_SCIM" in app.config and app.config["CANAILLE_SCIM"]["ENABLE_SERVER"]: + import canaille.scim.endpoints + + app.register_blueprint(canaille.scim.endpoints.bp) + def setup_flask(app): csrf.init_app(app) diff --git a/canaille/app/configuration.py b/canaille/app/configuration.py index 48ccfada..640d36f3 100644 --- a/canaille/app/configuration.py +++ b/canaille/app/configuration.py @@ -103,6 +103,13 @@ def settings_factory(config, env_file=None, env_prefix=""): attributes["CANAILLE_OIDC"] = ((OIDCSettings | None), None) + if "CANAILLE_SCIM" in config or any( + var.startswith("CANAILLE_SCIM__") for var in os.environ + ): + from canaille.scim.configuration import SCIMSettings + + attributes["CANAILLE_SCIM"] = ((SCIMSettings | None), None) + Settings = create_model( "Settings", __base__=RootSettings, diff --git a/canaille/app/themes.py b/canaille/app/themes.py index 2150a1a3..728df038 100644 --- a/canaille/app/themes.py +++ b/canaille/app/themes.py @@ -40,6 +40,10 @@ if flask_themer: @app.errorhandler(404) def page_not_found(error): + if flask.request.path.startswith("/scim/"): + from canaille.scim.endpoints import scim_error_handler + + return scim_error_handler(error) return render_template("error.html", description=error, error_code=404), 404 @app.errorhandler(500) diff --git a/canaille/backends/memory/backend.py b/canaille/backends/memory/backend.py index ca0df63c..04031a1a 100644 --- a/canaille/backends/memory/backend.py +++ b/canaille/backends/memory/backend.py @@ -10,6 +10,9 @@ from canaille.backends import Backend def listify(value): + if value is None: + return [] + return value if isinstance(value, list) else [value] diff --git a/canaille/scim/__init__.py b/canaille/scim/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/canaille/scim/configuration.py b/canaille/scim/configuration.py new file mode 100644 index 00000000..793e4d3a --- /dev/null +++ b/canaille/scim/configuration.py @@ -0,0 +1,7 @@ +from pydantic import BaseModel + + +class SCIMSettings(BaseModel): + """SCIM settings.""" + + ENABLE_SERVER: bool = True diff --git a/canaille/scim/endpoints.py b/canaille/scim/endpoints.py new file mode 100644 index 00000000..043593b3 --- /dev/null +++ b/canaille/scim/endpoints.py @@ -0,0 +1,502 @@ +from http import HTTPStatus + +from flask import Blueprint +from flask import Response +from flask import abort +from flask import request +from flask import url_for +from scim2_models import Address +from scim2_models import AuthenticationScheme +from scim2_models import Bulk +from scim2_models import ChangePassword +from scim2_models import Context +from scim2_models import Email +from scim2_models import EnterpriseUser +from scim2_models import Error +from scim2_models import ETag +from scim2_models import Filter +from scim2_models import Group +from scim2_models import GroupMember +from scim2_models import GroupMembership +from scim2_models import ListResponse +from scim2_models import Meta +from scim2_models import Name +from scim2_models import Patch +from scim2_models import PhoneNumber +from scim2_models import Photo +from scim2_models import Required +from scim2_models import Resource +from scim2_models import ResourceType +from scim2_models import Schema +from scim2_models import SearchRequest +from scim2_models import ServiceProviderConfig +from scim2_models import Sort +from scim2_models import User +from werkzeug.exceptions import HTTPException + +from canaille import csrf +from canaille.app import models +from canaille.backends import Backend + +bp = Blueprint("scim", __name__, url_prefix="/scim") + +# At the difference of the SCIM Group, Canaille Group must have a display_name +group_schema = Group.to_schema() +group_schema.attributes[0].required = Required.true +Group = Resource.from_schema(group_schema) + + +@bp.after_request +def add_scim_content_type(response): + response.headers["Content-Type"] = "application/scim+json" + return response + + +@bp.errorhandler(HTTPException) +def scim_error_handler(error): + return Error(detail=str(error), status=error.code).model_dump(), error.code + + +def parse_search_request(request) -> SearchRequest: + """Create a SearchRequest object from the request arguments.""" + max_nb_items_per_page = 1000 + count = ( + min(request.args["count"], max_nb_items_per_page) + if request.args.get("count") + else None + ) + req = SearchRequest( + attributes=request.args.get("attributes"), + excluded_attributes=request.args.get("excludedAttributes"), + start_index=request.args.get("startIndex"), + count=count, + ) + return req + + +def get_resource_types(): + """The resource types implemented by Canaille.""" + + return { + "User": ResourceType( + id="User", + name="User", + endpoint=url_for("scim.query_users", _external=True), + description="User accounts", + schema_="urn:ietf:params:scim:schemas:core:2.0:User", + meta=Meta( + resource_type="ResourceType", + location=url_for( + "scim.query_resource_type", + resource_type_name="User", + _external=True, + ), + ), + ), + "Group": ResourceType( + id="Group", + name="Group", + endpoint=url_for("scim.query_groups", _external=True), + description="Group management", + schema_="urn:ietf:params:scim:schemas:core:2.0:Group", + meta=Meta( + resource_type="ResourceType", + location=url_for( + "scim.query_resource_type", + resource_type_name="Group", + _external=True, + ), + ), + ), + } + + +def get_schemas(): + schemas = { + "urn:ietf:params:scim:schemas:core:2.0:ServiceProviderConfig": ServiceProviderConfig.to_schema(), + "urn:ietf:params:scim:schemas:core:2.0:ResourceType": ResourceType.to_schema(), + "urn:ietf:params:scim:schemas:core:2.0:Schema": Schema.to_schema(), + "urn:ietf:params:scim:schemas:core:2.0:User": User.to_schema(), + "urn:ietf:params:scim:schemas:core:2.0:Group": Group.to_schema(), + "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User": EnterpriseUser.to_schema(), + } + for schema_id, schema in schemas.items(): + schema.meta = Meta( + resource_type="Schema", + location=url_for("scim.query_schema", schema_id=schema_id, _external=True), + ) + return schemas + + +def user_from_canaille_to_scim(user): + scim_user = User[EnterpriseUser]( + meta=Meta( + resource_type="User", + created=user.created, + last_modified=user.last_modified, + location=url_for("scim.query_user", user=user, _external=True), + ), + id=user.id, + user_name=user.user_name, + # password=user.password, + preferred_language=user.preferred_language, + name=Name( + formatted=user.formatted_name, + family_name=user.family_name, + given_name=user.given_name, + ) + if (user.formatted_name or user.family_name or user.given_name) + else None, + display_name=user.display_name, + title=user.title, + profile_url=user.profile_url, + emails=[ + Email( + value=email, + primary=email == user.emails[0], + ) + for email in user.emails or [] + ] + or None, + phone_numbers=[ + PhoneNumber( + value=phone_number, primary=phone_number == user.phone_numbers[0] + ) + for phone_number in user.phone_numbers or [] + ] + or None, + addresses=[ + Address( + formatted=user.formatted_address, + street_address=user.street, + postal_code=user.postal_code, + locality=user.locality, + region=user.region, + primary=True, + ) + ] + if ( + user.formatted_address + or user.street + or user.postal_code + or user.locality + or user.region + ) + else None, + photos=[ + Photo( + value=url_for( + "core.account.photo", user=user, field="photo", _external=True + ), + primary=True, + type=Photo.Type.photo, + ) + ] + if user.photo + else None, + groups=[ + GroupMembership( + value=group.id, + display=group.display_name, + ref=url_for("scim.query_group", group=group, _external=True), + ) + for group in user.groups or [] + ] + or None, + ) + scim_user[EnterpriseUser] = EnterpriseUser( + employee_number=user.employee_number, + organization=user.organization, + department=user.department, + ) + return scim_user + + +def user_from_scim_to_canaille(scim_user: User, user): + user.user_name = scim_user.user_name + user.password = scim_user.password + user.preferred_language = scim_user.preferred_language + user.formatted_name = scim_user.name.formatted if scim_user.name else None + user.family_name = scim_user.name.family_name if scim_user.name else None + user.given_name = scim_user.name.given_name if scim_user.name else None + user.display_name = scim_user.display_name + user.title = scim_user.title + user.profile_url = scim_user.profile_url + user.emails = [email.value for email in scim_user.emails or []] or None + user.phone_numbers = [ + phone_number.value for phone_number in scim_user.phone_numbers or [] + ] or None + user.formatted_address = ( + scim_user.addresses[0].formatted if scim_user.addresses else None + ) + user.street = scim_user.addresses[0].street_address if scim_user.addresses else None + user.postal_code = ( + scim_user.addresses[0].postal_code if scim_user.addresses else None + ) + user.locality = scim_user.addresses[0].locality if scim_user.addresses else None + user.region = scim_user.addresses[0].region if scim_user.addresses else None + # TODO: delete the photo + # if scim_user.photos and scim_user.photos[0].value: + # user.photo = scim_user.photos[0].value + user.employee_number = ( + scim_user[EnterpriseUser].employee_number if scim_user[EnterpriseUser] else None + ) + user.organization = ( + scim_user[EnterpriseUser].organization if scim_user[EnterpriseUser] else None + ) + user.department = ( + scim_user[EnterpriseUser].department if scim_user[EnterpriseUser] else None + ) + user.groups = [ + Backend.instance.get(models.Group, group.value) + for group in scim_user.groups or [] + if group.value + ] + return user + + +def group_from_canaille_to_scim(group): + return Group( + id=group.id, + meta=Meta( + resource_type="Group", + created=group.created, + last_modified=group.last_modified, + location=url_for("scim.query_group", group=group, _external=True), + ), + display_name=group.display_name, + members=[ + GroupMember( + value=user.id, + type="User", + display=user.display_name, + ref=url_for("scim.query_user", user=user, _external=True), + ) + for user in group.members or [] + ] + or None, + ) + + +def group_from_scim_to_canaille(scim_group: Group, group): + group.display_name = scim_group.display_name + + members = [] + for member in scim_group.members or []: + Backend.instance.get(models.User, member.value) + group.members = members + + return group + + +@bp.route("/Users", methods=["GET"]) +@csrf.exempt +def query_users(): + req = parse_search_request(request) + start_index_1 = req.start_index or 1 + start_index_0 = (start_index_1 - 1) or None + stop_index_0 = (start_index_1 + req.count - 1) if req.count else None + users = list(Backend.instance.query(models.User)[start_index_0:stop_index_0]) + total = len(users) + scim_users = [user_from_canaille_to_scim(user) for user in users] + list_response = ListResponse[User[EnterpriseUser]]( + start_index=start_index_1, + items_per_page=req.count, + total_results=total, + resources=scim_users, + ) + payload = list_response.model_dump( + scim_ctx=Context.RESOURCE_QUERY_RESPONSE, + ) + return payload + + +@bp.route("/Users/", methods=["GET"]) +@csrf.exempt +def query_user(user): + scim_user = user_from_canaille_to_scim(user) + return scim_user.model_dump( + scim_ctx=Context.RESOURCE_QUERY_RESPONSE, + ) + + +@bp.route("/Groups", methods=["GET"]) +@csrf.exempt +def query_groups(): + req = parse_search_request(request) + start_index_1 = req.start_index or 1 + start_index_0 = (start_index_1 - 1) or None + stop_index_0 = (start_index_1 + req.count - 1) if req.count else None + groups = list(Backend.instance.query(models.group)[start_index_0:stop_index_0]) + total = len(groups) + scim_groups = [group_from_canaille_to_scim(group) for group in groups] + list_response = ListResponse[Group]( + start_index=start_index_1, + items_per_page=req.count, + total_results=total, + resources=scim_groups, + ) + payload = list_response.model_dump( + scim_ctx=Context.RESOURCE_QUERY_RESPONSE, + ) + return payload + + +@bp.route("/Groups/", methods=["GET"]) +@csrf.exempt +def query_group(group): + scim_group = group_from_canaille_to_scim(group) + return scim_group.model_dump( + scim_ctx=Context.RESOURCE_QUERY_RESPONSE, + ) + + +@bp.route("/Schemas", methods=["GET"]) +@csrf.exempt +def query_schemas(): + req = parse_search_request(request) + start_index_1 = req.start_index or 1 + start_index_0 = (start_index_1 - 1) or None + stop_index_0 = (start_index_1 + req.count - 1) if req.count else None + schemas = list(get_schemas().values())[start_index_0:stop_index_0] + response = ListResponse[Schema]( + total_results=len(schemas), + items_per_page=req.count or len(schemas), + start_index=start_index_1, + resources=schemas, + ) + return response.model_dump(scim_ctx=Context.RESOURCE_QUERY_RESPONSE) + + +@bp.route("/Schemas/", methods=["GET"]) +@csrf.exempt +def query_schema(schema_id): + schema = get_schemas().get(schema_id) + if not schema: + abort(404) + + return schema.model_dump(scim_ctx=Context.RESOURCE_QUERY_RESPONSE) + + +@bp.route("/ResourceTypes", methods=["GET"]) +@csrf.exempt +def query_resource_types(): + req = parse_search_request(request) + start_index_1 = req.start_index or 1 + start_index_0 = (start_index_1 - 1) or None + stop_index_0 = (start_index_1 + req.count - 1) if req.count else None + resource_types = list(get_resource_types().values())[start_index_0:stop_index_0] + response = ListResponse[ResourceType]( + total_results=len(resource_types), + items_per_page=req.count or len(resource_types), + start_index=start_index_1, + resources=resource_types, + ) + return response.model_dump(scim_ctx=Context.RESOURCE_QUERY_RESPONSE) + + +@bp.route("/ResourceTypes/", methods=["GET"]) +@csrf.exempt +def query_resource_type(resource_type_name): + resource_type = get_resource_types().get(resource_type_name) + if not resource_type: + abort(404) + + return resource_type.model_dump(scim_ctx=Context.RESOURCE_QUERY_RESPONSE) + + +@bp.route("/ServiceProviderConfig", methods=["GET"]) +@csrf.exempt +def query_service_provider_config(): + spc = ServiceProviderConfig( + meta=Meta( + resource_type="ServiceProviderConfig", + location=url_for("scim.query_service_provider_config", _external=True), + ), + documentation_uri="https://canaille.readthedocs.io", + patch=Patch(supported=False), + bulk=Bulk(supported=False, max_operations=0, max_payload_size=0), + change_password=ChangePassword(supported=True), + filter=Filter(supported=False, max_results=0), + sort=Sort(supported=False), + etag=ETag(supported=False), + authentication_schemes=[ + AuthenticationScheme( + name="OAuth Bearer Token", + description="Authentication scheme using the OAuth Bearer Token Standard", + spec_uri="http://www.rfc-editor.org/info/rfc6750", + documentation_uri="https://canaille.readthedocs.io", + type="oauthbearertoken", + primary=True, + ), + ], + ) + return spc.model_dump(scim_ctx=Context.RESOURCE_QUERY_RESPONSE) + + +@bp.route("/Users", methods=["POST"]) +@csrf.exempt +def create_user(): + request_user = User[EnterpriseUser].model_validate( + request.json, scim_ctx=Context.RESOURCE_CREATION_REQUEST + ) + user = user_from_scim_to_canaille(request_user, models.User()) + Backend.instance.save(user) + response_user = user_from_canaille_to_scim(user) + payload = response_user.model_dump_json(scim_ctx=Context.RESOURCE_CREATION_RESPONSE) + return Response(payload, status=HTTPStatus.CREATED) + + +@bp.route("/Groups", methods=["POST"]) +@csrf.exempt +def create_group(): + request_group = Group.model_validate( + request.json, scim_ctx=Context.RESOURCE_CREATION_REQUEST + ) + group = group_from_scim_to_canaille(request_group, models.Group()) + Backend.instance.save(group) + response_group = group_from_canaille_to_scim(group) + payload = response_group.model_dump_json( + scim_ctx=Context.RESOURCE_CREATION_RESPONSE + ) + return Response(payload, status=HTTPStatus.CREATED) + + +@bp.route("/Users/", methods=["PUT"]) +@csrf.exempt +def replace_user(user): + request_user = User[EnterpriseUser].model_validate( + request.json, scim_ctx=Context.RESOURCE_REPLACEMENT_REQUEST + ) + user = user_from_scim_to_canaille(request_user, user) + Backend.instance.save(user) + response_user = user_from_canaille_to_scim(user) + payload = response_user.model_dump(scim_ctx=Context.RESOURCE_REPLACEMENT_RESPONSE) + return payload + + +@bp.route("/Groups/", methods=["PUT"]) +@csrf.exempt +def replace_group(group): + request_group = Group.model_validate( + request.json, scim_ctx=Context.RESOURCE_REPLACEMENT_REQUEST + ) + group = group_from_scim_to_canaille(request_group, group) + Backend.instance.save(group) + response_group = group_from_canaille_to_scim(group) + payload = response_group.model_dump(scim_ctx=Context.RESOURCE_REPLACEMENT_RESPONSE) + return payload + + +@bp.route("/Users/", methods=["DELETE"]) +@csrf.exempt +def delete_user(user): + Backend.instance.delete(user) + return "", HTTPStatus.NO_CONTENT + + +@bp.route("/Groups/", methods=["DELETE"]) +@csrf.exempt +def delete_group(group): + Backend.instance.delete(group) + return "", HTTPStatus.NO_CONTENT diff --git a/demo/conf/canaille-ldap.toml b/demo/conf/canaille-ldap.toml index d0cfbcd4..20d8d0bc 100644 --- a/demo/conf/canaille-ldap.toml +++ b/demo/conf/canaille-ldap.toml @@ -81,3 +81,5 @@ DYNAMIC_CLIENT_REGISTRATION_OPEN = true DYNAMIC_CLIENT_REGISTRATION_TOKENS = [ "xxxxxxx-yyyyyyy-zzzzzz", ] + +[CANAILLE_SCIM] diff --git a/demo/conf/canaille-memory.toml b/demo/conf/canaille-memory.toml index 6abbc4c8..b78fe114 100644 --- a/demo/conf/canaille-memory.toml +++ b/demo/conf/canaille-memory.toml @@ -71,3 +71,5 @@ DYNAMIC_CLIENT_REGISTRATION_OPEN = true DYNAMIC_CLIENT_REGISTRATION_TOKENS = [ "xxxxxxx-yyyyyyy-zzzzzz", ] + +[CANAILLE_SCIM] diff --git a/demo/conf/canaille-sql.toml b/demo/conf/canaille-sql.toml index 9eb47510..08628bd1 100644 --- a/demo/conf/canaille-sql.toml +++ b/demo/conf/canaille-sql.toml @@ -74,3 +74,5 @@ DYNAMIC_CLIENT_REGISTRATION_OPEN = true DYNAMIC_CLIENT_REGISTRATION_TOKENS = [ "xxxxxxx-yyyyyyy-zzzzzz", ] + +[CANAILLE_SCIM] diff --git a/pyproject.toml b/pyproject.toml index a924afb5..fbce15be 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -52,6 +52,10 @@ oidc = [ "authlib >= 1.3.0", ] +scim = [ + "scim2-models>=0.2.2", +] + ldap = [ "python-ldap >= 3.4.0", ] @@ -113,16 +117,16 @@ dev = [ "pytest-lazy-fixtures >= 1.0.7", "pytest-smtpd >= 0.1.0", "pytest-xdist >= 3.3.1", + "scim2-tester>=0.1.7", "slapd >= 0.1.5", "time-machine >= 2.14.1", "toml >= 0.10.0", "tox-uv >= 1.16.0", - -# Babel 2.14 does not directly depend on setuptools -# https://github.com/python-babel/babel/blob/40e60a1f6cf178d9f57fcc14f157ea1b2ab77361/CHANGES.rst?plain=1#L22-L24 -# and neither python 3.12 due to PEPĀ 632 -# https://peps.python.org/pep-0632/ - "setuptools >= 50.0.0; python_version>='3.12'" + # Babel 2.14 does not directly depend on setuptools + # https://github.com/python-babel/babel/blob/40e60a1f6cf178d9f57fcc14f157ea1b2ab77361/CHANGES.rst?plain=1#L22-L24 + # and neither python 3.12 due to PEPĀ 632 + # https://peps.python.org/pep-0632/ + "setuptools >= 50.0.0; python_version>='3.12'", ] doc = [ "autodoc-pydantic >= 2.0.1", diff --git a/tests/scim/__init__.py b/tests/scim/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/scim/conftest.py b/tests/scim/conftest.py new file mode 100644 index 00000000..856a37b9 --- /dev/null +++ b/tests/scim/conftest.py @@ -0,0 +1,15 @@ +import pytest + + +@pytest.fixture +def configuration(configuration): + configuration["CANAILLE_SCIM"] = { + "ENABLE_SERVER": True, + } + configuration["CANAILLE"]["LOGGING"]["loggers"]["httpx"] = { + "level": "INFO", + } + configuration["CANAILLE"]["LOGGING"]["loggers"]["httpcore"] = { + "level": "INFO", + } + return configuration diff --git a/tests/scim/test_scim_tester.py b/tests/scim/test_scim_tester.py new file mode 100644 index 00000000..df4f04b2 --- /dev/null +++ b/tests/scim/test_scim_tester.py @@ -0,0 +1,17 @@ +import pytest +from scim2_client.engines.werkzeug import TestSCIMClient +from scim2_tester import check_server + +from canaille.scim.endpoints import bp + + +def test_scim_tester(app, backend): + # currently the tester create empty groups because it cannot handle references + # but LDAP does not support empty groups + # https://github.com/python-scim/scim2-tester/issues/15 + + if "ldap" in backend.__class__.__module__: + pytest.skip() + + client = TestSCIMClient(app, scim_prefix=bp.url_prefix) + check_server(client, raise_exceptions=True) diff --git a/uv.lock b/uv.lock index c54537d5..bab489ad 100644 --- a/uv.lock +++ b/uv.lock @@ -164,6 +164,9 @@ postgresql = [ { name = "sqlalchemy-json" }, { name = "sqlalchemy-utils" }, ] +scim = [ + { name = "scim2-models" }, +] sentry = [ { name = "sentry-sdk" }, ] @@ -199,6 +202,7 @@ dev = [ { name = "pytest-lazy-fixtures" }, { name = "pytest-smtpd" }, { name = "pytest-xdist" }, + { name = "scim2-tester" }, { name = "setuptools", marker = "python_full_version >= '3.12'" }, { name = "slapd" }, { name = "time-machine" }, @@ -234,6 +238,7 @@ requires-dist = [ { name = "pytz", marker = "extra == 'front'", specifier = ">=2022.7" }, { name = "qrcode", marker = "extra == 'otp'", specifier = ">=8.0" }, { name = "requests", specifier = ">=2.32.3" }, + { name = "scim2-models", marker = "extra == 'scim'", specifier = ">=0.2.2" }, { name = "sentry-sdk", marker = "extra == 'sentry'", specifier = ">=2.0.0" }, { name = "smpplib", marker = "extra == 'sms'", specifier = ">=2.2.3" }, { name = "sqlalchemy", marker = "extra == 'sqlite'", specifier = ">=2.0.23" }, @@ -272,6 +277,7 @@ dev = [ { name = "pytest-lazy-fixtures", specifier = ">=1.0.7" }, { name = "pytest-smtpd", specifier = ">=0.1.0" }, { name = "pytest-xdist", specifier = ">=3.3.1" }, + { name = "scim2-tester", specifier = ">=0.1.7" }, { name = "setuptools", marker = "python_full_version >= '3.12'", specifier = ">=50.0.0" }, { name = "slapd", specifier = ">=0.1.5" }, { name = "time-machine", specifier = ">=2.14.1" }, @@ -1307,6 +1313,11 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/62/51/72c18c55cf2f46ff4f91ebcc8f75aa30f7305f3d726be3f4ebffb4ae972b/pydantic-2.10.3-py3-none-any.whl", hash = "sha256:be04d85bbc7b65651c5f8e6b9976ed9c6f41782a55524cef079a34a0bb82144d", size = 456997 }, ] +[package.optional-dependencies] +email = [ + { name = "email-validator" }, +] + [[package]] name = "pydantic-core" version = "2.27.1" @@ -1621,6 +1632,42 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f9/9b/335f9764261e915ed497fcdeb11df5dfd6f7bf257d4a6a2a686d80da4d54/requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6", size = 64928 }, ] +[[package]] +name = "scim2-client" +version = "0.4.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "scim2-models" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c9/44/1b228a6a680ca96a1274f2ca1dd22aa3e61e656c5e829c348b27f793dc9d/scim2_client-0.4.3.tar.gz", hash = "sha256:69f55e1c296cb018cb4d71954485b6dab8153bb59935647b1e063a659c141ede", size = 85428 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fa/75/d56f022664d6db564b0e85265ab5e97b49133f796ff1d502bd652e7c075f/scim2_client-0.4.3-py3-none-any.whl", hash = "sha256:051578f4e56e57149b1b6ea06c30cd4d831b8f2278c301e92421b0ebf524b813", size = 22373 }, +] + +[[package]] +name = "scim2-models" +version = "0.2.10" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic", extra = ["email"] }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d6/ab/30c537635c2f4591db3a74acc90d8bd5a87107a01645f6c5a64c9f9e7619/scim2_models-0.2.10.tar.gz", hash = "sha256:1cbdaab551ec9fd06b3eaf4d1540f7c60cf065fdd6932ee5493e109d33163e2f", size = 131248 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/97/8c/ec957904e8e2d3f8cfa83c65f144aaa72527a816a485881eb7d5fb75968c/scim2_models-0.2.10-py3-none-any.whl", hash = "sha256:a8576a6c7a87bcfce9c5851f58ea1361ccbb6c53452cc96e70a2dda571cedcea", size = 39925 }, +] + +[[package]] +name = "scim2-tester" +version = "0.1.10" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "scim2-client" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/25/e5/d2b682ab9c46da87622271126b7be53ab82650144bff50b2243f5593bca0/scim2_tester-0.1.10.tar.gz", hash = "sha256:bcfb8bd16d3f2101ae2ebdeb24e1865b6ed21b9120ad1ecb13f4a2628e26973c", size = 67237 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4f/53/6e0bb75472cd621dc447c7cc93a45c0d4d0756e63d9f56ee9ea705710c1c/scim2_tester-0.1.10-py3-none-any.whl", hash = "sha256:9dfc8dfdab00d4d89d4ce8d4b0330c74eadfa482a52b66927c342cdef35547de", size = 17619 }, +] + [[package]] name = "sentry-sdk" version = "2.19.2"