This commit is contained in:
Éloi Rivard 2024-12-07 16:34:12 +01:00
parent 10abb2013a
commit 92214d932d
No known key found for this signature in database
GPG key ID: 7EDA204EA57DD184
12 changed files with 2846 additions and 2356 deletions

View file

@ -40,10 +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 scim_error_handler
from canaille.scim.endpoints import http_error_handler
return scim_error_handler(error)
return http_error_handler(error)
return render_template("error.html", description=error, error_code=404), 404
@app.errorhandler(500)

View file

@ -1,56 +1,46 @@
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 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 pydantic import ValidationError
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")
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
# 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 = 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
@ -65,8 +55,33 @@ def add_scim_content_type(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):
return Error(detail=str(error), status=error.code).model_dump(), error.code
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:
@ -86,234 +101,18 @@ def parse_search_request(request) -> SearchRequest:
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
@require_oauth()
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])
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=start_index_1,
start_index=req.start_index,
items_per_page=req.count,
total_results=total,
resources=scim_users,
@ -339,14 +138,13 @@ def query_user(user):
@require_oauth()
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])
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=start_index_1,
start_index=req.start_index,
items_per_page=req.count,
total_results=total,
resources=scim_groups,
@ -372,14 +170,11 @@ def query_group(group):
@require_oauth()
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]
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=start_index_1,
start_index=req.start_index,
resources=schemas,
)
return response.model_dump(scim_ctx=Context.RESOURCE_QUERY_RESPONSE)
@ -401,14 +196,13 @@ def query_schema(schema_id):
@require_oauth()
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]
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=start_index_1,
start_index=req.start_index,
resources=resource_types,
)
return response.model_dump(scim_ctx=Context.RESOURCE_QUERY_RESPONSE)
@ -429,29 +223,7 @@ def query_resource_type(resource_type_name):
@csrf.exempt
@require_oauth()
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,
),
],
)
spc = get_service_provider_config()
return spc.model_dump(scim_ctx=Context.RESOURCE_QUERY_RESPONSE)
@ -489,13 +261,18 @@ def create_group():
@csrf.exempt
@require_oauth()
def replace_user(user):
request_user = User[EnterpriseUser].model_validate(
request.json, scim_ctx=Context.RESOURCE_REPLACEMENT_REQUEST
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
)
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
@ -503,12 +280,15 @@ def replace_user(user):
@csrf.exempt
@require_oauth()
def replace_group(group):
request_group = Group.model_validate(
request.json, scim_ctx=Context.RESOURCE_REPLACEMENT_REQUEST
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,
)
group = group_from_scim_to_canaille(request_group, group)
Backend.instance.save(group)
response_group = group_from_canaille_to_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

291
canaille/scim/models.py Normal file
View 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

View file

@ -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`_ | ❌  | ❔ | ❔ | ✅ | ✅ | ❌ | ✅ | ✅ | ❔ |
+---------------+-------+-----------+------+------+------+------+------+------+-------+

File diff suppressed because it is too large Load diff

View file

@ -7,4 +7,5 @@ Tutorial
install
deployment
databases
provisioning
troubleshooting

View 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.

View file

@ -71,4 +71,5 @@ def scim_client(app, oidc_client, oidc_token):
Client(app),
scim_prefix=bp.url_prefix,
environ={"headers": {"Authorization": f"Bearer {oidc_token.access_token}"}},
check_response_status_codes=False,
)

View file

@ -1,45 +0,0 @@
import datetime
import pytest
from scim2_client import SCIMResponseErrorObject
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
def test_authentication_failure(app):
"""Test authentication with an invalid token."""
scim_client = TestSCIMClient(
Client(app),
scim_prefix=bp.url_prefix,
environ={"headers": {"Authorization": "Bearer invalid"}},
)
with pytest.raises(SCIMResponseErrorObject):
scim_client.discover()
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)
scim_client = TestSCIMClient(
Client(app),
scim_prefix=bp.url_prefix,
environ={"headers": {"Authorization": f"Bearer {scim_token.access_token}"}},
)
with pytest.raises(SCIMResponseErrorObject):
scim_client.discover()

76
tests/scim/test_errors.py Normal file
View 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

View file

@ -1,13 +1,7 @@
import pytest
from scim2_tester import Status
from scim2_tester import check_server
def test_scim_tester(scim_client, 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()
check_server(scim_client, raise_exceptions=True)
def test_scim_tester(scim_client):
results = check_server(scim_client, raise_exceptions=True)
assert all(result.status == Status.SUCCESS for result in results)

55
uv.lock
View file

@ -1636,38 +1636,38 @@ wheels = [
[[package]]
name = "scim2-client"
version = "0.5.0"
version = "0.5.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "scim2-models" },
]
sdist = { url = "https://files.pythonhosted.org/packages/4f/d0/06a2a68c8b6a840fd8020ebfaf0141e1eadff0a24b4a2ba87c1d0fb9607d/scim2_client-0.5.0.tar.gz", hash = "sha256:f485864c0148cbbddd6a4120a4b3c2553ca89a8076d5cf7bdfa8ad6aba2c1e6e", size = 85783 }
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/4b/e3/195d64ace80effcb948773914b72a8705565afbd02471ac827b28dfa977a/scim2_client-0.5.0-py3-none-any.whl", hash = "sha256:9f290aafea88d4220372a4902a17b3e7ea4dbdae69dfe9489b938d8d7a7ac827", size = 22500 },
{ 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.2.10"
version = "0.3.0"
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 }
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/97/8c/ec957904e8e2d3f8cfa83c65f144aaa72527a816a485881eb7d5fb75968c/scim2_models-0.2.10-py3-none-any.whl", hash = "sha256:a8576a6c7a87bcfce9c5851f58ea1361ccbb6c53452cc96e70a2dda571cedcea", size = 39925 },
{ 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.10"
version = "0.1.13"
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 }
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/4f/53/6e0bb75472cd621dc447c7cc93a45c0d4d0756e63d9f56ee9ea705710c1c/scim2_tester-0.1.10-py3-none-any.whl", hash = "sha256:9dfc8dfdab00d4d89d4ce8d4b0330c74eadfa482a52b66927c342cdef35547de", size = 17619 },
{ url = "https://files.pythonhosted.org/packages/8a/4f/d81e4983089bf458c59a1dd85d8524adf77b5b0d9e7aba3f9f2057da0343/scim2_tester-0.1.13-py3-none-any.whl", hash = "sha256:88d1832c4d13b369184e2a0c1a1aed6b107679b591e47eab73441ba382cb5add", size = 18879 },
]
[[package]]
@ -2138,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]]