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) @app.errorhandler(404)
def page_not_found(error): 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/"): 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 return render_template("error.html", description=error, error_code=404), 404
@app.errorhandler(500) @app.errorhandler(500)

View file

@ -1,56 +1,46 @@
import json
from http import HTTPStatus from http import HTTPStatus
from authlib.integrations.flask_oauth2 import ResourceProtector from authlib.integrations.flask_oauth2 import ResourceProtector
from authlib.integrations.flask_oauth2.errors import (
_HTTPException as AuthlibHTTPException,
)
from authlib.oauth2.rfc6750 import BearerTokenValidator from authlib.oauth2.rfc6750 import BearerTokenValidator
from flask import Blueprint from flask import Blueprint
from flask import Response from flask import Response
from flask import abort from flask import abort
from flask import request from flask import request
from flask import url_for from pydantic import ValidationError
from scim2_models import Address
from scim2_models import AuthenticationScheme
from scim2_models import Bulk
from scim2_models import ChangePassword
from scim2_models import Context from scim2_models import Context
from scim2_models import Email
from scim2_models import EnterpriseUser from scim2_models import EnterpriseUser
from scim2_models import Error 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 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 ResourceType
from scim2_models import Schema from scim2_models import Schema
from scim2_models import SearchRequest 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 werkzeug.exceptions import HTTPException
from canaille import csrf from canaille import csrf
from canaille.app import models from canaille.app import models
from canaille.backends import Backend 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 bp = Blueprint("scim", __name__, url_prefix="/scim/v2")
group_schema = Group.to_schema()
group_schema.attributes[0].required = Required.true
Group = Resource.from_schema(group_schema)
class SCIMBearerTokenValidator(BearerTokenValidator): class SCIMBearerTokenValidator(BearerTokenValidator):
def authenticate_token(self, token_string: str): def authenticate_token(self, token_string: str):
token = Backend.instance.get(models.Token, access_token=token_string) 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 return token if token and not token.subject else None
@ -65,8 +55,33 @@ def add_scim_content_type(response):
@bp.errorhandler(HTTPException) @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): 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: def parse_search_request(request) -> SearchRequest:
@ -86,234 +101,18 @@ def parse_search_request(request) -> SearchRequest:
return req 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"]) @bp.route("/Users", methods=["GET"])
@csrf.exempt @csrf.exempt
@require_oauth() @require_oauth()
def query_users(): def query_users():
req = parse_search_request(request) req = parse_search_request(request)
start_index_1 = req.start_index or 1 users = list(
start_index_0 = (start_index_1 - 1) or None Backend.instance.query(models.User)[req.start_index_0 : req.stop_index_0]
stop_index_0 = (start_index_1 + req.count - 1) if req.count else None )
users = list(Backend.instance.query(models.User)[start_index_0:stop_index_0])
total = len(users) total = len(users)
scim_users = [user_from_canaille_to_scim(user) for user in users] scim_users = [user_from_canaille_to_scim(user) for user in users]
list_response = ListResponse[User[EnterpriseUser]]( list_response = ListResponse[User[EnterpriseUser]](
start_index=start_index_1, start_index=req.start_index,
items_per_page=req.count, items_per_page=req.count,
total_results=total, total_results=total,
resources=scim_users, resources=scim_users,
@ -339,14 +138,13 @@ def query_user(user):
@require_oauth() @require_oauth()
def query_groups(): def query_groups():
req = parse_search_request(request) req = parse_search_request(request)
start_index_1 = req.start_index or 1 groups = list(
start_index_0 = (start_index_1 - 1) or None Backend.instance.query(models.group)[req.start_index_0 : req.stop_index_0]
stop_index_0 = (start_index_1 + req.count - 1) if req.count else None )
groups = list(Backend.instance.query(models.group)[start_index_0:stop_index_0])
total = len(groups) total = len(groups)
scim_groups = [group_from_canaille_to_scim(group) for group in groups] scim_groups = [group_from_canaille_to_scim(group) for group in groups]
list_response = ListResponse[Group]( list_response = ListResponse[Group](
start_index=start_index_1, start_index=req.start_index,
items_per_page=req.count, items_per_page=req.count,
total_results=total, total_results=total,
resources=scim_groups, resources=scim_groups,
@ -372,14 +170,11 @@ def query_group(group):
@require_oauth() @require_oauth()
def query_schemas(): def query_schemas():
req = parse_search_request(request) req = parse_search_request(request)
start_index_1 = req.start_index or 1 schemas = list(get_schemas().values())[req.start_index_0 : req.stop_index_0]
start_index_0 = (start_index_1 - 1) or None
stop_index_0 = (start_index_1 + req.count - 1) if req.count else None
schemas = list(get_schemas().values())[start_index_0:stop_index_0]
response = ListResponse[Schema]( response = ListResponse[Schema](
total_results=len(schemas), total_results=len(schemas),
items_per_page=req.count or len(schemas), items_per_page=req.count or len(schemas),
start_index=start_index_1, start_index=req.start_index,
resources=schemas, resources=schemas,
) )
return response.model_dump(scim_ctx=Context.RESOURCE_QUERY_RESPONSE) return response.model_dump(scim_ctx=Context.RESOURCE_QUERY_RESPONSE)
@ -401,14 +196,13 @@ def query_schema(schema_id):
@require_oauth() @require_oauth()
def query_resource_types(): def query_resource_types():
req = parse_search_request(request) req = parse_search_request(request)
start_index_1 = req.start_index or 1 resource_types = list(get_resource_types().values())[
start_index_0 = (start_index_1 - 1) or None req.start_index_0 : req.stop_index_0
stop_index_0 = (start_index_1 + req.count - 1) if req.count else None ]
resource_types = list(get_resource_types().values())[start_index_0:stop_index_0]
response = ListResponse[ResourceType]( response = ListResponse[ResourceType](
total_results=len(resource_types), total_results=len(resource_types),
items_per_page=req.count or 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, resources=resource_types,
) )
return response.model_dump(scim_ctx=Context.RESOURCE_QUERY_RESPONSE) return response.model_dump(scim_ctx=Context.RESOURCE_QUERY_RESPONSE)
@ -429,29 +223,7 @@ def query_resource_type(resource_type_name):
@csrf.exempt @csrf.exempt
@require_oauth() @require_oauth()
def query_service_provider_config(): def query_service_provider_config():
spc = ServiceProviderConfig( spc = get_service_provider_config()
meta=Meta(
resource_type="ServiceProviderConfig",
location=url_for("scim.query_service_provider_config", _external=True),
),
documentation_uri="https://canaille.readthedocs.io",
patch=Patch(supported=False),
bulk=Bulk(supported=False, max_operations=0, max_payload_size=0),
change_password=ChangePassword(supported=True),
filter=Filter(supported=False, max_results=0),
sort=Sort(supported=False),
etag=ETag(supported=False),
authentication_schemes=[
AuthenticationScheme(
name="OAuth Bearer Token",
description="Authentication scheme using the OAuth Bearer Token Standard",
spec_uri="http://www.rfc-editor.org/info/rfc6750",
documentation_uri="https://canaille.readthedocs.io",
type="oauthbearertoken",
primary=True,
),
],
)
return spc.model_dump(scim_ctx=Context.RESOURCE_QUERY_RESPONSE) return spc.model_dump(scim_ctx=Context.RESOURCE_QUERY_RESPONSE)
@ -489,13 +261,18 @@ def create_group():
@csrf.exempt @csrf.exempt
@require_oauth() @require_oauth()
def replace_user(user): def replace_user(user):
request_user = User[EnterpriseUser].model_validate( original_scim_user = user_from_canaille_to_scim(user)
request.json, scim_ctx=Context.RESOURCE_REPLACEMENT_REQUEST 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 return payload
@ -503,12 +280,15 @@ def replace_user(user):
@csrf.exempt @csrf.exempt
@require_oauth() @require_oauth()
def replace_group(group): def replace_group(group):
request_group = Group.model_validate( original_scim_group = group_from_canaille_to_scim(group)
request.json, scim_ctx=Context.RESOURCE_REPLACEMENT_REQUEST 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) updated_group = group_from_scim_to_canaille(request_scim_group, group)
Backend.instance.save(group) Backend.instance.save(updated_group)
response_group = group_from_canaille_to_scim(group) response_group = group_from_canaille_to_scim(updated_group)
payload = response_group.model_dump(scim_ctx=Context.RESOURCE_REPLACEMENT_RESPONSE) payload = response_group.model_dump(scim_ctx=Context.RESOURCE_REPLACEMENT_RESPONSE)
return payload 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 SCIM
---- ----
- ❌ `RFC7642: System for Cross-domain Identity Management: Definitions, Overview, Concepts, and Requirements <https://www.rfc-editor.org/rfc/rfc7642>`_ Canaille provides a basic SCIM server implementation.
- ❌ `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>`_ - 🟠 `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 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 | | | FLOSS | Language | LOC | OIDC | SAML | CAS | SCIM | LDAP | SQL |
+===============+=======+===========+======+======+======+======+======+======+=======+ +===============+=======+===========+======+======+======+======+======+======+=======+
| Canaille | ✅  | Python | 10k | ✅ | ❌ | ❌ | | ✅ | ✅ | | Canaille | ✅  | Python | 10k | ✅ | ❌ | ❌ | 🟠 | ✅ | ✅ |
+---------------+-------+-----------+------+------+------+------+------+------+-------+ +---------------+-------+-----------+------+------+------+------+------+------+-------+
| `Auth0`_ | ❌  | ❔ | ❔ | ✅ | ✅ | ❌ | ✅ | ✅ | ❔ | | `Auth0`_ | ❌  | ❔ | ❔ | ✅ | ✅ | ❌ | ✅ | ✅ | ❔ |
+---------------+-------+-----------+------+------+------+------+------+------+-------+ +---------------+-------+-----------+------+------+------+------+------+------+-------+

File diff suppressed because it is too large Load diff

View file

@ -7,4 +7,5 @@ Tutorial
install install
deployment deployment
databases databases
provisioning
troubleshooting 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), Client(app),
scim_prefix=bp.url_prefix, scim_prefix=bp.url_prefix,
environ={"headers": {"Authorization": f"Bearer {oidc_token.access_token}"}}, 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 from scim2_tester import check_server
def test_scim_tester(scim_client, backend): def test_scim_tester(scim_client):
# currently the tester create empty groups because it cannot handle references results = check_server(scim_client, raise_exceptions=True)
# but LDAP does not support empty groups assert all(result.status == Status.SUCCESS for result in results)
# https://github.com/python-scim/scim2-tester/issues/15
if "ldap" in backend.__class__.__module__:
pytest.skip()
check_server(scim_client, raise_exceptions=True)

55
uv.lock
View file

@ -1636,38 +1636,38 @@ wheels = [
[[package]] [[package]]
name = "scim2-client" name = "scim2-client"
version = "0.5.0" version = "0.5.1"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "scim2-models" }, { 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 = [ 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]] [[package]]
name = "scim2-models" name = "scim2-models"
version = "0.2.10" version = "0.3.0"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "pydantic", extra = ["email"] }, { 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 = [ 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]] [[package]]
name = "scim2-tester" name = "scim2-tester"
version = "0.1.10" version = "0.1.13"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "scim2-client" }, { 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 = [ 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]] [[package]]
@ -2138,27 +2138,26 @@ wheels = [
[[package]] [[package]]
name = "uv" name = "uv"
version = "0.5.7" version = "0.5.8"
source = { registry = "https://pypi.org/simple" } 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 = [ 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/da/46/7a1310877b6ae012461c0bcc72629ee34a7c78749235ebf67d7856f24a91/uv-0.5.8-py3-none-linux_armv6l.whl", hash = "sha256:defd5da3685f43f74698634ffc197aaf9b836b8ba0de0e57b34d7bc74d856fa9", size = 14287864 },
{ 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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/51/3e/3826d2e7c653649eec649262d5548b7ed6bdb5af7bed2a8bb5a127ac67bd/uv-0.5.8-py3-none-win32.whl", hash = "sha256:f8ade0430b6618ae0e21e52f61f6f3943dd6f3184ef6dc4491087b27940427f9", size = 14201492 },
{ 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/2f/d3/8ab1383ceccbc9f31bb9a265f90dfda4f6214229768ea9608df8a8c66e15/uv-0.5.8-py3-none-win_amd64.whl", hash = "sha256:4a3325af8ed1effa7076967472c063b0000d609fd6f561c7751e43bab30297f1", size = 15995992 },
{ url = "https://files.pythonhosted.org/packages/e1/e0/2ce3eb10fab05d900b3434dce09f59f5ac0689e52ca4979e3bfd32e71b61/uv-0.5.7-py3-none-win_amd64.whl", hash = "sha256:27c630780e1856a70fbeb267e1ed6835268a1b50963ab9a984fafa4184389def", size = 15842701 },
] ]
[[package]] [[package]]