forked from Github-Mirrors/canaille
Merge branch '116-scim' into 'main'
Implement SCIM API Closes #116 See merge request yaal/canaille!197
This commit is contained in:
commit
24a41af54d
21 changed files with 3237 additions and 2000 deletions
|
@ -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)
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -40,6 +40,14 @@ if flask_themer:
|
|||
|
||||
@app.errorhandler(404)
|
||||
def page_not_found(error):
|
||||
# There is currently no way to make 404 handling generic
|
||||
# https://flask.palletsprojects.com/en/stable/errorhandling/#handling
|
||||
# However, the blueprint cannot handle 404 routing errors because the
|
||||
# 404 occurs at the routing level before the blueprint can be determined.
|
||||
if flask.request.path.startswith("/scim/"):
|
||||
from canaille.scim.endpoints import http_error_handler
|
||||
|
||||
return http_error_handler(error)
|
||||
return render_template("error.html", description=error, error_code=404), 404
|
||||
|
||||
@app.errorhandler(500)
|
||||
|
|
|
@ -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]
|
||||
|
||||
|
||||
|
|
0
canaille/scim/__init__.py
Normal file
0
canaille/scim/__init__.py
Normal file
7
canaille/scim/configuration.py
Normal file
7
canaille/scim/configuration.py
Normal file
|
@ -0,0 +1,7 @@
|
|||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class SCIMSettings(BaseModel):
|
||||
"""SCIM settings."""
|
||||
|
||||
ENABLE_SERVER: bool = True
|
309
canaille/scim/endpoints.py
Normal file
309
canaille/scim/endpoints.py
Normal file
|
@ -0,0 +1,309 @@
|
|||
import json
|
||||
from http import HTTPStatus
|
||||
|
||||
from authlib.integrations.flask_oauth2 import ResourceProtector
|
||||
from authlib.integrations.flask_oauth2.errors import (
|
||||
_HTTPException as AuthlibHTTPException,
|
||||
)
|
||||
from authlib.oauth2.rfc6750 import BearerTokenValidator
|
||||
from flask import Blueprint
|
||||
from flask import Response
|
||||
from flask import abort
|
||||
from flask import request
|
||||
from pydantic import ValidationError
|
||||
from scim2_models import Context
|
||||
from scim2_models import EnterpriseUser
|
||||
from scim2_models import Error
|
||||
from scim2_models import ListResponse
|
||||
from scim2_models import ResourceType
|
||||
from scim2_models import Schema
|
||||
from scim2_models import SearchRequest
|
||||
from werkzeug.exceptions import HTTPException
|
||||
|
||||
from canaille import csrf
|
||||
from canaille.app import models
|
||||
from canaille.backends import Backend
|
||||
|
||||
from .models import Group
|
||||
from .models import User
|
||||
from .models import get_resource_types
|
||||
from .models import get_schemas
|
||||
from .models import get_service_provider_config
|
||||
from .models import group_from_canaille_to_scim
|
||||
from .models import group_from_scim_to_canaille
|
||||
from .models import user_from_canaille_to_scim
|
||||
from .models import user_from_scim_to_canaille
|
||||
|
||||
bp = Blueprint("scim", __name__, url_prefix="/scim/v2")
|
||||
|
||||
|
||||
class SCIMBearerTokenValidator(BearerTokenValidator):
|
||||
def authenticate_token(self, token_string: str):
|
||||
token = Backend.instance.get(models.Token, access_token=token_string)
|
||||
# At the moment, only client tokens are allowed, and not user tokens
|
||||
return token if token and not token.subject else None
|
||||
|
||||
|
||||
require_oauth = ResourceProtector()
|
||||
require_oauth.register_token_validator(SCIMBearerTokenValidator())
|
||||
|
||||
|
||||
@bp.after_request
|
||||
def add_scim_content_type(response):
|
||||
response.headers["Content-Type"] = "application/scim+json"
|
||||
return response
|
||||
|
||||
|
||||
@bp.errorhandler(HTTPException)
|
||||
def http_error_handler(error):
|
||||
obj = Error(detail=str(error), status=error.code)
|
||||
return obj.model_dump(), obj.status
|
||||
|
||||
|
||||
@bp.errorhandler(AuthlibHTTPException)
|
||||
def oauth2_error(error):
|
||||
body = json.loads(error.body)
|
||||
obj = Error(
|
||||
detail=f"{body['error']}: {body['error_description']}"
|
||||
if "error_description" in body
|
||||
else body["error"],
|
||||
status=error.code,
|
||||
)
|
||||
return obj.model_dump(), error.code
|
||||
|
||||
|
||||
@bp.errorhandler(ValidationError)
|
||||
def scim_error_handler(error):
|
||||
error_details = error.errors()[0]
|
||||
obj = Error(status=400, detail=error_details["msg"])
|
||||
# TODO: maybe the Pydantic <=> SCIM error code mapping could go in scim2_models
|
||||
obj.scim_type = (
|
||||
"invalidValue" if error_details["type"] == "required_error" else None
|
||||
)
|
||||
|
||||
return obj.model_dump(), obj.status
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
@bp.route("/Users", methods=["GET"])
|
||||
@csrf.exempt
|
||||
@require_oauth()
|
||||
def query_users():
|
||||
req = parse_search_request(request)
|
||||
users = list(
|
||||
Backend.instance.query(models.User)[req.start_index_0 : req.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=req.start_index,
|
||||
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/<user:user>", methods=["GET"])
|
||||
@csrf.exempt
|
||||
@require_oauth()
|
||||
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
|
||||
@require_oauth()
|
||||
def query_groups():
|
||||
req = parse_search_request(request)
|
||||
groups = list(
|
||||
Backend.instance.query(models.group)[req.start_index_0 : req.stop_index_0]
|
||||
)
|
||||
total = len(groups)
|
||||
scim_groups = [group_from_canaille_to_scim(group) for group in groups]
|
||||
list_response = ListResponse[Group](
|
||||
start_index=req.start_index,
|
||||
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/<group:group>", methods=["GET"])
|
||||
@csrf.exempt
|
||||
@require_oauth()
|
||||
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
|
||||
@require_oauth()
|
||||
def query_schemas():
|
||||
req = parse_search_request(request)
|
||||
schemas = list(get_schemas().values())[req.start_index_0 : req.stop_index_0]
|
||||
response = ListResponse[Schema](
|
||||
total_results=len(schemas),
|
||||
items_per_page=req.count or len(schemas),
|
||||
start_index=req.start_index,
|
||||
resources=schemas,
|
||||
)
|
||||
return response.model_dump(scim_ctx=Context.RESOURCE_QUERY_RESPONSE)
|
||||
|
||||
|
||||
@bp.route("/Schemas/<string:schema_id>", methods=["GET"])
|
||||
@csrf.exempt
|
||||
@require_oauth()
|
||||
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
|
||||
@require_oauth()
|
||||
def query_resource_types():
|
||||
req = parse_search_request(request)
|
||||
resource_types = list(get_resource_types().values())[
|
||||
req.start_index_0 : req.stop_index_0
|
||||
]
|
||||
response = ListResponse[ResourceType](
|
||||
total_results=len(resource_types),
|
||||
items_per_page=req.count or len(resource_types),
|
||||
start_index=req.start_index,
|
||||
resources=resource_types,
|
||||
)
|
||||
return response.model_dump(scim_ctx=Context.RESOURCE_QUERY_RESPONSE)
|
||||
|
||||
|
||||
@bp.route("/ResourceTypes/<string:resource_type_name>", methods=["GET"])
|
||||
@csrf.exempt
|
||||
@require_oauth()
|
||||
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
|
||||
@require_oauth()
|
||||
def query_service_provider_config():
|
||||
spc = get_service_provider_config()
|
||||
return spc.model_dump(scim_ctx=Context.RESOURCE_QUERY_RESPONSE)
|
||||
|
||||
|
||||
@bp.route("/Users", methods=["POST"])
|
||||
@csrf.exempt
|
||||
@require_oauth()
|
||||
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
|
||||
@require_oauth()
|
||||
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/<user:user>", methods=["PUT"])
|
||||
@csrf.exempt
|
||||
@require_oauth()
|
||||
def replace_user(user):
|
||||
original_scim_user = user_from_canaille_to_scim(user)
|
||||
request_scim_user = User[EnterpriseUser].model_validate(
|
||||
request.json,
|
||||
scim_ctx=Context.RESOURCE_REPLACEMENT_REQUEST,
|
||||
original=original_scim_user,
|
||||
)
|
||||
updated_user = user_from_scim_to_canaille(request_scim_user, user)
|
||||
Backend.instance.save(updated_user)
|
||||
response_scim_user = user_from_canaille_to_scim(updated_user)
|
||||
payload = response_scim_user.model_dump(
|
||||
scim_ctx=Context.RESOURCE_REPLACEMENT_RESPONSE
|
||||
)
|
||||
return payload
|
||||
|
||||
|
||||
@bp.route("/Groups/<group:group>", methods=["PUT"])
|
||||
@csrf.exempt
|
||||
@require_oauth()
|
||||
def replace_group(group):
|
||||
original_scim_group = group_from_canaille_to_scim(group)
|
||||
request_scim_group = Group.model_validate(
|
||||
request.json,
|
||||
scim_ctx=Context.RESOURCE_REPLACEMENT_REQUEST,
|
||||
original=original_scim_group,
|
||||
)
|
||||
updated_group = group_from_scim_to_canaille(request_scim_group, group)
|
||||
Backend.instance.save(updated_group)
|
||||
response_group = group_from_canaille_to_scim(updated_group)
|
||||
payload = response_group.model_dump(scim_ctx=Context.RESOURCE_REPLACEMENT_RESPONSE)
|
||||
return payload
|
||||
|
||||
|
||||
@bp.route("/Users/<user:user>", methods=["DELETE"])
|
||||
@csrf.exempt
|
||||
@require_oauth()
|
||||
def delete_user(user):
|
||||
Backend.instance.delete(user)
|
||||
return "", HTTPStatus.NO_CONTENT
|
||||
|
||||
|
||||
@bp.route("/Groups/<group:group>", methods=["DELETE"])
|
||||
@csrf.exempt
|
||||
@require_oauth()
|
||||
def delete_group(group):
|
||||
Backend.instance.delete(group)
|
||||
return "", HTTPStatus.NO_CONTENT
|
291
canaille/scim/models.py
Normal file
291
canaille/scim/models.py
Normal file
|
@ -0,0 +1,291 @@
|
|||
from flask import url_for
|
||||
from scim2_models import AuthenticationScheme
|
||||
from scim2_models import Bulk
|
||||
from scim2_models import ChangePassword
|
||||
from scim2_models import EnterpriseUser
|
||||
from scim2_models import ETag
|
||||
from scim2_models import Filter
|
||||
from scim2_models import Group
|
||||
from scim2_models import Meta
|
||||
from scim2_models import Mutability
|
||||
from scim2_models import Patch
|
||||
from scim2_models import Required
|
||||
from scim2_models import Resource
|
||||
from scim2_models import ResourceType
|
||||
from scim2_models import Schema
|
||||
from scim2_models import SchemaExtension
|
||||
from scim2_models import ServiceProviderConfig
|
||||
from scim2_models import Sort
|
||||
from scim2_models import User
|
||||
|
||||
from canaille.app import models
|
||||
from canaille.backends import Backend
|
||||
|
||||
# At the difference of SCIM User, Canaille User need a 'family_name'
|
||||
# (because the LDAP 'sn' is mandatory) and the 'user_name'
|
||||
# attribute is immutable (because it is part of the LDAP DN).
|
||||
user_schema = User.to_schema()
|
||||
user_schema["name"].required = Required.true
|
||||
user_schema["name"]["familyName"].required = Required.true
|
||||
user_schema["userName"].mutability = Mutability.immutable
|
||||
User = Resource.from_schema(user_schema)
|
||||
|
||||
# At the difference of the SCIM Group, Canaille Group must have a display_name.
|
||||
# and 'members' cannot be null.
|
||||
group_schema = Group.to_schema()
|
||||
group_schema["displayName"].required = Required.true
|
||||
group_schema["displayName"].mutability = Mutability.immutable
|
||||
group_schema["members"].required = Required.true
|
||||
group_schema["members"]["value"].required = Required.true
|
||||
group_schema["members"]["$ref"].required = Required.true
|
||||
Group = Resource.from_schema(group_schema)
|
||||
|
||||
|
||||
def get_service_provider_config():
|
||||
return 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,
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
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",
|
||||
schema_extensions=[
|
||||
SchemaExtension(
|
||||
schema_="urn:ietf:params:scim:schemas:extension:enterprise:2.0:User",
|
||||
required=True,
|
||||
)
|
||||
],
|
||||
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=User.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=[
|
||||
User.Emails(
|
||||
value=email,
|
||||
primary=email == user.emails[0],
|
||||
)
|
||||
for email in user.emails or []
|
||||
]
|
||||
or None,
|
||||
phone_numbers=[
|
||||
User.PhoneNumbers(
|
||||
value=phone_number, primary=phone_number == user.phone_numbers[0]
|
||||
)
|
||||
for phone_number in user.phone_numbers or []
|
||||
]
|
||||
or None,
|
||||
addresses=[
|
||||
User.Addresses(
|
||||
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=[
|
||||
User.Photos(
|
||||
value=url_for(
|
||||
"core.account.photo", user=user, field="photo", _external=True
|
||||
),
|
||||
primary=True,
|
||||
type=User.Photos.Type.photo,
|
||||
)
|
||||
]
|
||||
if user.photo
|
||||
else None,
|
||||
groups=[
|
||||
User.Groups(
|
||||
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=[
|
||||
Group.Members(
|
||||
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 []:
|
||||
# extract the user identifier from scim/v2/Users/<identifier>
|
||||
identifier = member.ref.split("/")[-1]
|
||||
members.append(Backend.instance.get(models.User, identifier))
|
||||
|
||||
group.members = members
|
||||
|
||||
return group
|
|
@ -81,3 +81,5 @@ DYNAMIC_CLIENT_REGISTRATION_OPEN = true
|
|||
DYNAMIC_CLIENT_REGISTRATION_TOKENS = [
|
||||
"xxxxxxx-yyyyyyy-zzzzzz",
|
||||
]
|
||||
|
||||
[CANAILLE_SCIM]
|
||||
|
|
|
@ -71,3 +71,5 @@ DYNAMIC_CLIENT_REGISTRATION_OPEN = true
|
|||
DYNAMIC_CLIENT_REGISTRATION_TOKENS = [
|
||||
"xxxxxxx-yyyyyyy-zzzzzz",
|
||||
]
|
||||
|
||||
[CANAILLE_SCIM]
|
||||
|
|
|
@ -74,3 +74,5 @@ DYNAMIC_CLIENT_REGISTRATION_OPEN = true
|
|||
DYNAMIC_CLIENT_REGISTRATION_TOKENS = [
|
||||
"xxxxxxx-yyyyyyy-zzzzzz",
|
||||
]
|
||||
|
||||
[CANAILLE_SCIM]
|
||||
|
|
|
@ -48,9 +48,52 @@ OpenID Connect
|
|||
SCIM
|
||||
----
|
||||
|
||||
- ❌ `RFC7642: System for Cross-domain Identity Management: Definitions, Overview, Concepts, and Requirements <https://www.rfc-editor.org/rfc/rfc7642>`_
|
||||
- ❌ `RFC7643: System for Cross-domain Identity Management: Core Schema <https://www.rfc-editor.org/rfc/rfc7642>`_
|
||||
- ❌ `RFC7644: System for Cross-domain Identity Management: Protocol <https://www.rfc-editor.org/rfc/rfc7642>`_
|
||||
Canaille provides a basic SCIM server implementation.
|
||||
|
||||
- 🟠 `RFC7642: System for Cross-domain Identity Management: Definitions, Overview, Concepts, and Requirements <https://www.rfc-editor.org/rfc/rfc7642>`_
|
||||
- 🟠 `RFC7643: System for Cross-domain Identity Management: Core Schema <https://www.rfc-editor.org/rfc/rfc7642>`_
|
||||
- 🟠 `RFC7644: System for Cross-domain Identity Management: Protocol <https://www.rfc-editor.org/rfc/rfc7642>`_
|
||||
|
||||
Client-side implementation (i.e. broadcasting changes on users and groups among clients) and advanced features will be implemented in the future.
|
||||
|
||||
What's implemented
|
||||
~~~~~~~~~~~~~~~~~~
|
||||
|
||||
Endpoints:
|
||||
|
||||
- /Users (GET, POST)
|
||||
- /Users/<user_id> (GET, PUT, DELETE)
|
||||
- /Groups (GET, POST)
|
||||
- /Groups/<user_id> (GET, PUT, DELETE)
|
||||
- /ServiceProviderConfig (GET)
|
||||
- /Schemas (GET)
|
||||
- /Schemas/<schema_id> (GET)
|
||||
- /ResourceTypes (GET)
|
||||
- /ResourceTypes/<resource_type_id> (GET)
|
||||
|
||||
Features:
|
||||
|
||||
- :rfc:`pagination <7644#section-3.4.2.4>`
|
||||
|
||||
.. _scim_unimplemented:
|
||||
|
||||
What is not implemented yet
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
Endpoints:
|
||||
|
||||
- /Users (PATCH)
|
||||
- /Groups (PATCH)
|
||||
- :rfc:`/Me <7644#section-3.11>` (GET, POST, PUT, PATCH, DELETE)
|
||||
- :rfc:`/Bulk <7644#section-3.11>` (POST)
|
||||
- :rfc:`/.search <7644#section-3.4.3>` (POST)
|
||||
|
||||
Features
|
||||
|
||||
- :rfc:`filtering <7644#section-3.4.2.2>`
|
||||
- :rfc:`sorting <7644#section-3.4.2.3>`
|
||||
- :rfc:`attributes selection <7644#section-3.4.2.5>`
|
||||
- :rfc:`ETags <7644#section-3.14>`
|
||||
|
||||
Comparison with other providers
|
||||
===============================
|
||||
|
@ -64,7 +107,7 @@ Canaille voluntarily only implements the OpenID Connect protocol to keep its cod
|
|||
| +-------+-----------+------+------+------+------+------+------+-------+
|
||||
| | FLOSS | Language | LOC | OIDC | SAML | CAS | SCIM | LDAP | SQL |
|
||||
+===============+=======+===========+======+======+======+======+======+======+=======+
|
||||
| Canaille | ✅ | Python | 10k | ✅ | ❌ | ❌ | ❌ | ✅ | ✅ |
|
||||
| Canaille | ✅ | Python | 10k | ✅ | ❌ | ❌ | 🟠 | ✅ | ✅ |
|
||||
+---------------+-------+-----------+------+------+------+------+------+------+-------+
|
||||
| `Auth0`_ | ❌ | ❔ | ❔ | ✅ | ✅ | ❌ | ✅ | ✅ | ❔ |
|
||||
+---------------+-------+-----------+------+------+------+------+------+------+-------+
|
||||
|
|
4269
doc/locales/doc.pot
4269
doc/locales/doc.pot
File diff suppressed because it is too large
Load diff
|
@ -7,4 +7,5 @@ Tutorial
|
|||
install
|
||||
deployment
|
||||
databases
|
||||
provisioning
|
||||
troubleshooting
|
||||
|
|
19
doc/tutorial/provisioning.rst
Normal file
19
doc/tutorial/provisioning.rst
Normal file
|
@ -0,0 +1,19 @@
|
|||
Provisioning
|
||||
############
|
||||
|
||||
Canaille partially implemnet the :rfc:`SCIM <7642>` provisioning protocol at the ``/scim/v2`` endpoint.
|
||||
|
||||
At the moment, only the server part is implemented.
|
||||
It allows client applications to manage user profiles directly in Canaille.
|
||||
|
||||
To allow clients to access the SCIM API, the client must have the ``client_credentials`` grant type configured.
|
||||
This allows clients to ask an authentication token on their own behalf and use this token to perform queries.
|
||||
Currently, user tokens are not supported.
|
||||
|
||||
.. todo::
|
||||
|
||||
Some SCIM :ref:`features and endpoints <scim_unimplemented>` are not implemented.
|
||||
In addition to these, Canaille will implement in the future:
|
||||
|
||||
- Access control for clients on the SCIM API endpoint, to finely manage permissions depending on clients.
|
||||
- Client-side implementation, to broadcast user and groups modifications among all the clients.
|
|
@ -52,6 +52,11 @@ oidc = [
|
|||
"authlib >= 1.3.0",
|
||||
]
|
||||
|
||||
scim = [
|
||||
"scim2-models>=0.2.2",
|
||||
"authlib >= 1.3.0",
|
||||
]
|
||||
|
||||
ldap = [
|
||||
"python-ldap >= 3.4.0",
|
||||
]
|
||||
|
@ -113,16 +118,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",
|
||||
|
|
0
tests/scim/__init__.py
Normal file
0
tests/scim/__init__.py
Normal file
75
tests/scim/conftest.py
Normal file
75
tests/scim/conftest.py
Normal file
|
@ -0,0 +1,75 @@
|
|||
import datetime
|
||||
|
||||
import pytest
|
||||
from scim2_client.engines.werkzeug import TestSCIMClient
|
||||
from werkzeug.security import gen_salt
|
||||
from werkzeug.test import Client
|
||||
|
||||
from canaille.app import models
|
||||
from canaille.scim.endpoints import bp
|
||||
|
||||
|
||||
@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
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def oidc_client(testclient, backend):
|
||||
c = models.Client(
|
||||
client_id=gen_salt(24),
|
||||
client_name="Some client",
|
||||
contacts=["contact@mydomain.test"],
|
||||
client_uri="https://mydomain.test",
|
||||
redirect_uris=[
|
||||
"https://mydomain.test/redirect1",
|
||||
],
|
||||
client_id_issued_at=datetime.datetime.now(datetime.timezone.utc),
|
||||
client_secret=gen_salt(48),
|
||||
grant_types=[
|
||||
"client_credentials",
|
||||
],
|
||||
response_types=["code", "token", "id_token"],
|
||||
scope=["openid", "email", "profile", "groups", "address", "phone"],
|
||||
token_endpoint_auth_method="client_secret_basic",
|
||||
)
|
||||
backend.save(c)
|
||||
|
||||
yield c
|
||||
backend.delete(c)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def oidc_token(testclient, oidc_client, backend):
|
||||
t = models.Token(
|
||||
token_id=gen_salt(48),
|
||||
access_token=gen_salt(48),
|
||||
audience=[oidc_client],
|
||||
client=oidc_client,
|
||||
refresh_token=gen_salt(48),
|
||||
scope=["openid", "profile"],
|
||||
issue_date=datetime.datetime.now(datetime.timezone.utc),
|
||||
lifetime=3600,
|
||||
)
|
||||
backend.save(t)
|
||||
yield t
|
||||
backend.delete(t)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def scim_client(app, oidc_client, oidc_token):
|
||||
return TestSCIMClient(
|
||||
Client(app),
|
||||
scim_prefix=bp.url_prefix,
|
||||
environ={"headers": {"Authorization": f"Bearer {oidc_token.access_token}"}},
|
||||
check_response_status_codes=False,
|
||||
)
|
76
tests/scim/test_errors.py
Normal file
76
tests/scim/test_errors.py
Normal file
|
@ -0,0 +1,76 @@
|
|||
import datetime
|
||||
|
||||
from scim2_client.engines.werkzeug import TestSCIMClient
|
||||
from scim2_models import Error
|
||||
from scim2_models import Resource
|
||||
from werkzeug.security import gen_salt
|
||||
from werkzeug.test import Client
|
||||
|
||||
from canaille.app import models
|
||||
from canaille.scim.endpoints import bp
|
||||
from canaille.scim.endpoints import get_resource_types
|
||||
from canaille.scim.endpoints import get_schemas
|
||||
from canaille.scim.endpoints import get_service_provider_config
|
||||
|
||||
|
||||
def test_authentication_failure(app):
|
||||
"""Test authentication with an invalid token."""
|
||||
resource_models = [
|
||||
Resource.from_schema(schema) for schema in get_schemas().values()
|
||||
]
|
||||
scim_client = TestSCIMClient(
|
||||
Client(app),
|
||||
scim_prefix=bp.url_prefix,
|
||||
environ={"headers": {"Authorization": "Bearer invalid"}},
|
||||
service_provider_config=get_service_provider_config(),
|
||||
resource_types=get_resource_types().values(),
|
||||
resource_models=resource_models,
|
||||
)
|
||||
User = scim_client.get_resource_model("User")
|
||||
error = scim_client.query(User, raise_scim_errors=False)
|
||||
assert isinstance(error, Error)
|
||||
assert not error.scim_type
|
||||
assert error.status == 401
|
||||
|
||||
|
||||
def test_authentication_with_an_user_token(app, backend, oidc_client, user):
|
||||
"""Test authentication with an user token."""
|
||||
scim_token = models.Token(
|
||||
token_id=gen_salt(48),
|
||||
access_token=gen_salt(48),
|
||||
subject=user,
|
||||
audience=[oidc_client],
|
||||
client=oidc_client,
|
||||
refresh_token=gen_salt(48),
|
||||
scope=["openid", "profile"],
|
||||
issue_date=datetime.datetime.now(datetime.timezone.utc),
|
||||
lifetime=3600,
|
||||
)
|
||||
backend.save(scim_token)
|
||||
|
||||
resource_models = [
|
||||
Resource.from_schema(schema) for schema in get_schemas().values()
|
||||
]
|
||||
scim_client = TestSCIMClient(
|
||||
Client(app),
|
||||
scim_prefix=bp.url_prefix,
|
||||
environ={"headers": {"Authorization": f"Bearer {scim_token.access_token}"}},
|
||||
service_provider_config=get_service_provider_config(),
|
||||
resource_types=get_resource_types().values(),
|
||||
resource_models=resource_models,
|
||||
)
|
||||
User = scim_client.get_resource_model("User")
|
||||
error = scim_client.query(User, raise_scim_errors=False)
|
||||
assert isinstance(error, Error)
|
||||
assert not error.scim_type
|
||||
assert error.status == 401
|
||||
|
||||
|
||||
def test_invalid_payload(app, backend, scim_client):
|
||||
# TODO: push this test in scim2-tester
|
||||
scim_client.discover()
|
||||
User = scim_client.get_resource_model("User")
|
||||
error = scim_client.create(User(), raise_scim_errors=False)
|
||||
assert isinstance(error, Error)
|
||||
assert error.scim_type == "invalidValue"
|
||||
assert error.status == 400
|
7
tests/scim/test_scim_tester.py
Normal file
7
tests/scim/test_scim_tester.py
Normal file
|
@ -0,0 +1,7 @@
|
|||
from scim2_tester import Status
|
||||
from scim2_tester import check_server
|
||||
|
||||
|
||||
def test_scim_tester(scim_client):
|
||||
results = check_server(scim_client, raise_exceptions=True)
|
||||
assert all(result.status == Status.SUCCESS for result in results)
|
86
uv.lock
86
uv.lock
|
@ -164,6 +164,10 @@ postgresql = [
|
|||
{ name = "sqlalchemy-json" },
|
||||
{ name = "sqlalchemy-utils" },
|
||||
]
|
||||
scim = [
|
||||
{ name = "authlib" },
|
||||
{ name = "scim2-models" },
|
||||
]
|
||||
sentry = [
|
||||
{ name = "sentry-sdk" },
|
||||
]
|
||||
|
@ -199,6 +203,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" },
|
||||
|
@ -218,6 +223,7 @@ doc = [
|
|||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "authlib", marker = "extra == 'oidc'", specifier = ">=1.3.0" },
|
||||
{ name = "authlib", marker = "extra == 'scim'", specifier = ">=1.3.0" },
|
||||
{ name = "email-validator", marker = "extra == 'front'", specifier = ">=2.0.0" },
|
||||
{ name = "flask", specifier = ">=3.0.0" },
|
||||
{ name = "flask-babel", marker = "extra == 'front'", specifier = ">=4.0.0" },
|
||||
|
@ -234,6 +240,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 +279,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 +1315,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 +1634,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.5.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "scim2-models" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/60/a0/208fb622495b174cfa11f9e856c19db73f2bbf3859519704cd35ff39dfce/scim2_client-0.5.1.tar.gz", hash = "sha256:836451f91baf8f0f3c7061dcc043e892d4e607c55c5803e779adc112b4bc2722", size = 85832 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c8/9f/66b3c5a61b156856f1019538757063a96e9cdd11b9d4f812505148c66d29/scim2_client-0.5.1-py3-none-any.whl", hash = "sha256:bf5566da5704228d24eebc89cd8a2adb038b19099956996d1e450a1d40df2d14", size = 22507 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "scim2-models"
|
||||
version = "0.3.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pydantic", extra = ["email"] },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d9/42/e7f986b1ebfba7f8b6105764aec0a8c526100b0d8bfd9e28cf08432ad693/scim2_models-0.3.0.tar.gz", hash = "sha256:a1db62385e7820e67c94fd758246815397eb3b3bd1bca797a2a3ef346e81d827", size = 132910 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/6a/ed/708165169928aae94a0596a557e4c674bcc781e429802fb951b9af1d21b1/scim2_models-0.3.0-py3-none-any.whl", hash = "sha256:1ff78d93b9ed0a4d89f04835777e1cee796e0d1881e71f8242a86af2ec9f0612", size = 40663 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "scim2-tester"
|
||||
version = "0.1.13"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "scim2-client" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ac/c7/fa5672d24d68da5e1060e4f02ab946d7569457d1a5d969e72c0f6c9e78c6/scim2_tester-0.1.13.tar.gz", hash = "sha256:2697f1ca8938e9f4425b76803856f4053108fbe3aee65a1bff446ca178339319", size = 68200 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/4f/d81e4983089bf458c59a1dd85d8524adf77b5b0d9e7aba3f9f2057da0343/scim2_tester-0.1.13-py3-none-any.whl", hash = "sha256:88d1832c4d13b369184e2a0c1a1aed6b107679b591e47eab73441ba382cb5add", size = 18879 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sentry-sdk"
|
||||
version = "2.19.2"
|
||||
|
@ -2089,27 +2138,26 @@ wheels = [
|
|||
|
||||
[[package]]
|
||||
name = "uv"
|
||||
version = "0.5.7"
|
||||
version = "0.5.8"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ca/1c/8c40ec75c26656bec9ada97833a437b49fd443b5d6dfd61d6dda8ad90cbe/uv-0.5.7.tar.gz", hash = "sha256:4d22a5046a6246af85c92257d110ed8fbcd98b16824e4efa9d825d001222b2cb", size = 2356161 }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/14/31/24c4d8d0d15f5a596fefb39a45e5628e2a4ac4b9c0a6044b4710d118673a/uv-0.5.8.tar.gz", hash = "sha256:2ee40bc9c08fea0e71092838c0fc36df83f741807d8be9acf2fd4c4757b3171e", size = 2494559 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/15/4d05061146ef1ff909458f75812633944a144ebadf73ccd38bef127adc6a/uv-0.5.7-py3-none-linux_armv6l.whl", hash = "sha256:fb4a3ccbe13072b98919413ac8378dd3e2b5480352f75c349a4f71f423801485", size = 14208956 },
|
||||
{ url = "https://files.pythonhosted.org/packages/ba/8f/dc99e8f026da8b3c74661ca60d424472b8fc73854be8dd0375c9a487474b/uv-0.5.7-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:a4fc62749bda8e7ae62212b1d85cdf6c7bad41918b3c8ac5a6d730dd093d793d", size = 14205195 },
|
||||
{ url = "https://files.pythonhosted.org/packages/fe/67/fba55047c34ceae31cf92f6286a8517749d8c86a2151620fccb4dfb01cba/uv-0.5.7-py3-none-macosx_11_0_arm64.whl", hash = "sha256:78c3c040e52c09a410b9788656d6e760d557f223058537081cb03a3e25ce89de", size = 13178700 },
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/af/476c4d3486690e3cd6a9d1e040e350aefcd374b6adf919228594c9e0d9d2/uv-0.5.7-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:76b514c79136e779cccf90cce5d60f317a0d42074e9f4c059f198ef435f2f6ab", size = 13438725 },
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/18/ab89b12e695e069f6a181f66fd22dfa66b3bb5b7508938a4d4a3bff6d214/uv-0.5.7-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a45648db157d2aaff859fe71ec738efea09b972b8864feb2fd61ef856a15b24f", size = 13987146 },
|
||||
{ url = "https://files.pythonhosted.org/packages/60/72/0eedd9b4d25657124ee5715ec08a0b278716905dd4c2a79b2af5e742c421/uv-0.5.7-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c1e7b5bcc8b380e333e948c01f6f4c6203067b5de60a05f8ed786332af7a9132", size = 14513180 },
|
||||
{ url = "https://files.pythonhosted.org/packages/9c/b3/feef463577bb31f692b2e52fdce76865d297fe1a4ae48d2bad855b255a67/uv-0.5.7-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:737a06b15c4e6b8ab7dd0a577ba766380bda4c18ba4ecfcfff37d336f1b03a00", size = 15216614 },
|
||||
{ url = "https://files.pythonhosted.org/packages/99/dd/90e3360402610e1f687fc52c1c0b12906530986c7fe87d63414e0b8ac045/uv-0.5.7-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ba25eb99891b95b5200d5e369b788d443fae370b097e7268a71e9ba753f2af3f", size = 15005351 },
|
||||
{ url = "https://files.pythonhosted.org/packages/f2/c5/1fd7eafa61d2659ab4b27314e01eaa2cd62acb0f3a8bceb6420d38f3137f/uv-0.5.7-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:747c011da9f631354a1c89b62b19b8572e040d3fe01c6fb8d650facc7a09fdbb", size = 19537320 },
|
||||
{ url = "https://files.pythonhosted.org/packages/12/77/36eb833476111af75ecc624d103662aba650b2b3c47abf4df5917697a5b1/uv-0.5.7-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a141b40444c4184efba9fdc10abb3c1cff32154c7f8b0ad46ddc180d65a82d90", size = 14678070 },
|
||||
{ url = "https://files.pythonhosted.org/packages/a9/c6/7a70672f383ec639d178e0b1481048f181c05bbe372f23a66853a02e0346/uv-0.5.7-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:46b03a9a78438219fb3060c096773284e2f22417a9c1f8fdd602f0650b3355c2", size = 13637987 },
|
||||
{ url = "https://files.pythonhosted.org/packages/98/d1/a7c80c0a582344cf63ad17c8c344c9194a2f4475f6b522adbdb3b8cb6ac6/uv-0.5.7-py3-none-musllinux_1_1_armv7l.whl", hash = "sha256:13961a8116515eb288c4f91849fba11ebda0dfeec44cc356e388b3b03b2dbbe1", size = 13974519 },
|
||||
{ url = "https://files.pythonhosted.org/packages/84/23/55ef8f1fdd750aa1a123dac92bac249cbf8268bd9ab5b63b33580cd4dc23/uv-0.5.7-py3-none-musllinux_1_1_i686.whl", hash = "sha256:071b57c934bdee8d7502a70e9ea0739a10e9b2d1d0c67e923a09e7a23d9a181b", size = 14241488 },
|
||||
{ url = "https://files.pythonhosted.org/packages/e8/42/0cb96aa85849e55f3dcf4080fec1c13e75eb6179cbff630e4ded22b455f6/uv-0.5.7-py3-none-musllinux_1_1_ppc64le.whl", hash = "sha256:1c5b89c64fb627f52f1e9c9bbc4dcc7bae29c4c5ab8eff46da3c966bbd4caed2", size = 16082215 },
|
||||
{ url = "https://files.pythonhosted.org/packages/c5/d0/51e588ef932160f113a379781b7edf781d2a7e4667ff4a26b1f3146df359/uv-0.5.7-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:b79e32438390add793bebc41b0729054e375be30bc53f124ee212d9c97affc39", size = 14809685 },
|
||||
{ url = "https://files.pythonhosted.org/packages/cc/2b/5cc8622473e61b252211811ee6cb0471ac060dc4a36391747217a717a19a/uv-0.5.7-py3-none-win32.whl", hash = "sha256:d0600d2b2fbd9a9446bfbb7f03d88bc3d0293b949ce40e326429dd4fe246c926", size = 14074020 },
|
||||
{ url = "https://files.pythonhosted.org/packages/e1/e0/2ce3eb10fab05d900b3434dce09f59f5ac0689e52ca4979e3bfd32e71b61/uv-0.5.7-py3-none-win_amd64.whl", hash = "sha256:27c630780e1856a70fbeb267e1ed6835268a1b50963ab9a984fafa4184389def", size = 15842701 },
|
||||
{ url = "https://files.pythonhosted.org/packages/da/46/7a1310877b6ae012461c0bcc72629ee34a7c78749235ebf67d7856f24a91/uv-0.5.8-py3-none-linux_armv6l.whl", hash = "sha256:defd5da3685f43f74698634ffc197aaf9b836b8ba0de0e57b34d7bc74d856fa9", size = 14287864 },
|
||||
{ url = "https://files.pythonhosted.org/packages/0f/b5/d02c8ce6bf46d648e9ef912308718a30ecff631904ba03acd11e5ec6412d/uv-0.5.8-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:e146062e4cc39db334cbde38d56d2c6301dd9cf6739ce07ce5a4d71b4cbc2d00", size = 14290268 },
|
||||
{ url = "https://files.pythonhosted.org/packages/fb/5e/7277f92ee0aa8549e41152d9a0a7863d84e7b7b8de9b08cb397bfe1e37f6/uv-0.5.8-py3-none-macosx_11_0_arm64.whl", hash = "sha256:0f2bcdd00a49ad1669e217a2787448cac1653c9968d74bfa3732f3c25ca26f69", size = 13255149 },
|
||||
{ url = "https://files.pythonhosted.org/packages/08/5b/72be4ba38e8e6cd2be60e97fd799629228afd3f46404767b0e1cfcf1236e/uv-0.5.8-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:c91d0a2b8218af2aa0385b867da8c13a620db22077686793c7231f012cb40619", size = 13541600 },
|
||||
{ url = "https://files.pythonhosted.org/packages/4d/cb/92485fea5f3fffb0f93820fe808b56ceeef1020ae234f8e2ba64f091ed4e/uv-0.5.8-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8058ab06d2f69355694f6e9a36edc45164474c516b4e2895bd67f8232d9022ed", size = 14090419 },
|
||||
{ url = "https://files.pythonhosted.org/packages/ac/b0/09a3a3d93299728485121b975a84b893aebdb6b712f65f43491bba7f82d0/uv-0.5.8-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c56022edc0f61febbdef89e6f699a0e991932c493b7293635b4814e102d040d2", size = 14638200 },
|
||||
{ url = "https://files.pythonhosted.org/packages/3c/52/1082d3ca50d336035b5ef6c54caa4936aa2a6ad050ea61fca3068dd986b3/uv-0.5.8-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:84f26ce1736d075d1df34f7c3f6b0b728cecd9a4da3e5160d5d887587830e7ce", size = 15336063 },
|
||||
{ url = "https://files.pythonhosted.org/packages/06/b5/d9d9a95646ca2404da11fa8f1e9953827ad793d8b92b65bb870f4c0de541/uv-0.5.8-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a7956787658fb9253fba49741886409402a48039bee64b1697397d27284919af", size = 15068797 },
|
||||
{ url = "https://files.pythonhosted.org/packages/96/18/f92f7bf7b8769f8010ae4a9b545a0a183a806133174f65c46996e23c8268/uv-0.5.8-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5989bbbbca072edc1875036c76aed74ec3dfc4741de7d1f060e181717efea6ac", size = 19540106 },
|
||||
{ url = "https://files.pythonhosted.org/packages/a4/d8/757959dc58abfbf09afe024fbcf1ffb639b8537ea830d09a99d0300ee53c/uv-0.5.8-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2b3076c79746d4f83257c9dea5ba0833b0711aeff8e6695670eadd140a0cf67f", size = 14760582 },
|
||||
{ url = "https://files.pythonhosted.org/packages/be/20/8b97777fbe6b983a845237c3132e4b540b9dcde73c2bc7c7c6f96ff46f29/uv-0.5.8-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:aa03c338e19456d3a6544a94293bd2905837ae22720cc161c83ea0fd13c3b09f", size = 13738416 },
|
||||
{ url = "https://files.pythonhosted.org/packages/b4/fe/fd462516eeb6d58acf5736ea4e7b1b397454344d99c9a0c279bb96436c7b/uv-0.5.8-py3-none-musllinux_1_1_armv7l.whl", hash = "sha256:8a8cbe1ffa0ef5c2f1c90622e07211a8f93f48daa2be1bd4592bb8cda52b0285", size = 14044658 },
|
||||
{ url = "https://files.pythonhosted.org/packages/be/d0/215c4fcd68e02f39c50557829365e75e60de2c246884753f1382bd75513e/uv-0.5.8-py3-none-musllinux_1_1_i686.whl", hash = "sha256:365eb6bbb551c5623a73b1ed530f4e69083016f70f0cf5ca1a30ec66413bcda2", size = 14359764 },
|
||||
{ url = "https://files.pythonhosted.org/packages/41/3e/3d96e9c41cee4acf16aee39f4cae81f5651754ac6ca383be2031efc90eeb/uv-0.5.8-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:56715389d240ac989af2188cd3bfc2b603d31b42330e915dacfe113b34d8e65b", size = 14943042 },
|
||||
{ url = "https://files.pythonhosted.org/packages/51/3e/3826d2e7c653649eec649262d5548b7ed6bdb5af7bed2a8bb5a127ac67bd/uv-0.5.8-py3-none-win32.whl", hash = "sha256:f8ade0430b6618ae0e21e52f61f6f3943dd6f3184ef6dc4491087b27940427f9", size = 14201492 },
|
||||
{ url = "https://files.pythonhosted.org/packages/2f/d3/8ab1383ceccbc9f31bb9a265f90dfda4f6214229768ea9608df8a8c66e15/uv-0.5.8-py3-none-win_amd64.whl", hash = "sha256:4a3325af8ed1effa7076967472c063b0000d609fd6f561c7751e43bab30297f1", size = 15995992 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
Loading…
Reference in a new issue