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