diff --git a/.gitignore b/.gitignore index 943acb5e..ca6b3659 100644 --- a/.gitignore +++ b/.gitignore @@ -24,3 +24,4 @@ canaille/conf/*.pem canaille/conf/*.pub canaille/conf/*.key .vscode +dump.json diff --git a/canaille/backends/ldap/models.py b/canaille/backends/ldap/models.py index 8a04b689..5d90c41b 100644 --- a/canaille/backends/ldap/models.py +++ b/canaille/backends/ldap/models.py @@ -1,5 +1,4 @@ import ldap.filter -from flask import current_app import canaille.core.models import canaille.oidc.models @@ -51,8 +50,7 @@ class User(canaille.core.models.User, LDAPObject): return super().match_filter(filter) def save(self): - if current_app.features.has_otp and not self.secret_token: - self.initialize_otp() + super().save() group_attr = self.python_attribute_to_ldap("groups") if group_attr not in self.changes: diff --git a/canaille/backends/memory/backend.py b/canaille/backends/memory/backend.py index 853218cb..d8c3aedc 100644 --- a/canaille/backends/memory/backend.py +++ b/canaille/backends/memory/backend.py @@ -5,7 +5,6 @@ from typing import Any from flask import current_app -import canaille.backends.memory.models from canaille.backends import Backend from canaille.backends import get_lockout_delay_message @@ -138,12 +137,9 @@ class MemoryBackend(Backend): return results[0] if results else None def save(self, instance): - if ( - isinstance(instance, canaille.backends.memory.models.User) - and current_app.features.has_otp - and not instance.secret_token - ): - instance.initialize_otp() + # run the instance save callback if existing + if hasattr(instance, "save"): + instance.save() if not instance.id: instance.id = str(uuid.uuid4()) @@ -160,14 +156,11 @@ class MemoryBackend(Backend): def delete(self, instance): # run the instance delete callback if existing - delete_callback = instance.delete() if hasattr(instance, "delete") else iter([]) - next(delete_callback, None) + if hasattr(instance, "delete"): + instance.delete() self.index_delete(instance) - # run the instance delete callback again if existing - next(delete_callback, None) - def reload(self, instance): # run the instance reload callback if existing reload_callback = instance.reload() if hasattr(instance, "reload") else iter([]) diff --git a/canaille/backends/sql/backend.py b/canaille/backends/sql/backend.py index 726ecff7..5f5b0234 100644 --- a/canaille/backends/sql/backend.py +++ b/canaille/backends/sql/backend.py @@ -134,15 +134,12 @@ class SQLBackend(Backend): def delete(self, instance): # run the instance delete callback if existing - save_callback = instance.delete() if hasattr(instance, "delete") else iter([]) - next(save_callback, None) + if hasattr(instance, "delete"): + instance.delete() SQLBackend.instance.db_session.delete(instance) SQLBackend.instance.db_session.commit() - # run the instance delete callback again if existing - next(save_callback, None) - def reload(self, instance): # run the instance reload callback if existing reload_callback = instance.reload() if hasattr(instance, "reload") else iter([]) diff --git a/canaille/backends/sql/models.py b/canaille/backends/sql/models.py index caa1822a..4d90a440 100644 --- a/canaille/backends/sql/models.py +++ b/canaille/backends/sql/models.py @@ -2,7 +2,6 @@ import datetime import typing import uuid -from flask import current_app from sqlalchemy import Boolean from sqlalchemy import Column from sqlalchemy import ForeignKey @@ -112,10 +111,7 @@ class User(canaille.core.models.User, Base, SqlAlchemyModel): one_time_password_emission_date: Mapped[datetime.datetime] = mapped_column( TZDateTime(timezone=True), nullable=True ) - - def save(self): - if current_app.features.has_otp and not self.secret_token: - self.initialize_otp() + scim_id: Mapped[str] = mapped_column(String, nullable=True, unique=True) @property def password_failure_timestamps(self): diff --git a/canaille/core/models.py b/canaille/core/models.py index b1d23cda..7ea4f2c7 100644 --- a/canaille/core/models.py +++ b/canaille/core/models.py @@ -4,11 +4,17 @@ from typing import Annotated from typing import ClassVar from flask import current_app +from httpx import Client as httpx_client +from scim2_client.engines.httpx import SyncSCIMClient +from scim2_models import SearchRequest +from canaille.app import models +from canaille.backends import Backend from canaille.backends.models import Model from canaille.core.configuration import Permission from canaille.core.mails import send_one_time_password_mail from canaille.core.sms import send_one_time_password_sms +from canaille.scim.models import user_from_canaille_to_scim_for_client HOTP_LOOK_AHEAD_WINDOW = 10 OTP_DIGITS = 6 @@ -278,10 +284,20 @@ class User(Model): one_time_password_emission_date: datetime.datetime | None = None """A DateTime indicating when the user last emitted an email or sms one-time password.""" + scim_id: str | None = None + _readable_fields = None _writable_fields = None _permissions = None + def save(self): + if current_app.features.has_otp and not self.secret_token: + self.initialize_otp() + self.propagate_scim_changes() + + def delete(self): + self.propagate_scim_delete() + def has_password(self) -> bool: """Check whether a password has been set for the user.""" return self.password is not None @@ -486,6 +502,57 @@ class User(Model): ).total_seconds() return max(calculated_delay - time_since_last_failed_bind, 0) + def propagate_scim_changes(self): + for client in self.get_clients(): + tokens = Backend.instance.query(models.Token, subject=self, client=client) + if tokens: + token = tokens[0] + client = httpx_client( + base_url=client.client_uri, + headers={"Authorization": f"Bearer {token.access_token}"}, + ) + scim = SyncSCIMClient(client) + scim.discover() + User = scim.get_resource_model("User") + EnterpriseUser = User.get_extension_model("EnterpriseUser") + user = user_from_canaille_to_scim_for_client(self, User, EnterpriseUser) + + req = SearchRequest(filter=f'userName eq "{self.user_name}"') + response = scim.query(User, search_request=req) + + if not response: + try: + scim.create(user) + except: + current_app.logger.warning( + f"SCIM User {self.user_name} creation for client {client.client_name} failed" + ) + else: + user.id = response.id + try: + scim.replace(user) + except: + current_app.logger.warning( + f"SCIM User {self.user_name} update for client {client.client_name} failed" + ) + + def propagate_scim_delete(self): + client = httpx_client( + base_url="http://localhost:8080", + headers={"Authorization": "Bearer MON_SUPER_TOKEN"}, + ) + scim = SyncSCIMClient(client) + scim.discover() + User = scim.get_resource_model("User") + try: + scim.delete(User, self.scim_id) + except: + current_app.logger.warning(f"SCIM User {self.user_name} delete failed") + + def get_clients(self): + consents = Backend.instance.query(models.Consent, subject=self) + return {t.client for t in consents} + class Group(Model): """User model, based on the `SCIM Group schema diff --git a/canaille/scim/models.py b/canaille/scim/models.py index ce446215..65c54166 100644 --- a/canaille/scim/models.py +++ b/canaille/scim/models.py @@ -289,3 +289,86 @@ def group_from_scim_to_canaille(scim_group: Group, group): group.members = members return group + + +def user_from_canaille_to_scim_for_client(user, User, EnterpriseUser): + scim_user = User( + 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, + 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 diff --git a/pyproject.toml b/pyproject.toml index 5a66b968..07ae80c7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,6 +32,7 @@ requires-python = ">=3.10" dependencies = [ "flask >= 3.0.0", "flask-wtf >= 1.2.1", + "httpx>=0.28.1", "pydantic-settings >= 2.0.3", "requests>=2.32.3", "wtforms >= 3.1.1", diff --git a/uv.lock b/uv.lock index 7f8e0e91..c8e48be0 100644 --- a/uv.lock +++ b/uv.lock @@ -37,6 +37,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643 }, ] +[[package]] +name = "anyio" +version = "4.7.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "idna" }, + { name = "sniffio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f6/40/318e58f669b1a9e00f5c4453910682e2d9dd594334539c7b7817dabb765f/anyio-4.7.0.tar.gz", hash = "sha256:2f834749c602966b7d456a7567cafcb309f96482b5081d14ac93ccd457f9dd48", size = 177076 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/7a/4daaf3b6c08ad7ceffea4634ec206faeff697526421c20f07628c7372156/anyio-4.7.0-py3-none-any.whl", hash = "sha256:ea60c3723ab42ba6fff7e8ccb0488c898ec538ff4df1f1d5e642c3601d07e352", size = 93052 }, +] + [[package]] name = "atpublic" version = "5.0" @@ -126,6 +141,7 @@ source = { editable = "." } dependencies = [ { name = "flask" }, { name = "flask-wtf" }, + { name = "httpx" }, { name = "pydantic-settings" }, { name = "requests" }, { name = "wtforms" }, @@ -229,6 +245,7 @@ requires-dist = [ { name = "flask-babel", marker = "extra == 'front'", specifier = ">=4.0.0" }, { name = "flask-themer", marker = "extra == 'front'", specifier = ">=2.0.0" }, { name = "flask-wtf", specifier = ">=1.2.1" }, + { name = "httpx", specifier = ">=0.28.1" }, { name = "otpauth", marker = "extra == 'otp'", specifier = ">=2.1.1" }, { name = "passlib", marker = "extra == 'mysql'", specifier = ">=1.7.4" }, { name = "passlib", marker = "extra == 'postgresql'", specifier = ">=1.7.4" }, @@ -549,7 +566,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/98/65/13d9e76ca19b0ba5603d71ac8424b5694415b348e719db277b5edc985ff5/cryptography-44.0.0-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:761817a3377ef15ac23cd7834715081791d4ec77f9297ee694ca1ee9c2c7e5eb", size = 3915420 }, { url = "https://files.pythonhosted.org/packages/b1/07/40fe09ce96b91fc9276a9ad272832ead0fddedcba87f1190372af8e3039c/cryptography-44.0.0-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3c672a53c0fb4725a29c303be906d3c1fa99c32f58abe008a82705f9ee96f40b", size = 4154498 }, { url = "https://files.pythonhosted.org/packages/75/ea/af65619c800ec0a7e4034207aec543acdf248d9bffba0533342d1bd435e1/cryptography-44.0.0-cp37-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:4ac4c9f37eba52cb6fbeaf5b59c152ea976726b865bd4cf87883a7e7006cc543", size = 3932569 }, - { url = "https://files.pythonhosted.org/packages/4e/d5/9cc182bf24c86f542129565976c21301d4ac397e74bf5a16e48241aab8a6/cryptography-44.0.0-cp37-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:60eb32934076fa07e4316b7b2742fa52cbb190b42c2df2863dbc4230a0a9b385", size = 4164756 }, { url = "https://files.pythonhosted.org/packages/c7/af/d1deb0c04d59612e3d5e54203159e284d3e7a6921e565bb0eeb6269bdd8a/cryptography-44.0.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:ed3534eb1090483c96178fcb0f8893719d96d5274dfde98aa6add34614e97c8e", size = 4016721 }, { url = "https://files.pythonhosted.org/packages/bd/69/7ca326c55698d0688db867795134bdfac87136b80ef373aaa42b225d6dd5/cryptography-44.0.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:f3f6fdfa89ee2d9d496e2c087cebef9d4fcbb0ad63c40e821b39f74bf48d9c5e", size = 4240915 }, { url = "https://files.pythonhosted.org/packages/ef/d4/cae11bf68c0f981e0413906c6dd03ae7fa864347ed5fac40021df1ef467c/cryptography-44.0.0-cp37-abi3-win32.whl", hash = "sha256:eb33480f1bad5b78233b0ad3e1b0be21e8ef1da745d8d2aecbb20671658b9053", size = 2757925 }, @@ -560,7 +576,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d0/c7/c656eb08fd22255d21bc3129625ed9cd5ee305f33752ef2278711b3fa98b/cryptography-44.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:c5eb858beed7835e5ad1faba59e865109f3e52b3783b9ac21e7e47dc5554e289", size = 3915417 }, { url = "https://files.pythonhosted.org/packages/ef/82/72403624f197af0db6bac4e58153bc9ac0e6020e57234115db9596eee85d/cryptography-44.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f53c2c87e0fb4b0c00fa9571082a057e37690a8f12233306161c8f4b819960b7", size = 4155160 }, { url = "https://files.pythonhosted.org/packages/a2/cd/2f3c440913d4329ade49b146d74f2e9766422e1732613f57097fea61f344/cryptography-44.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:9e6fc8a08e116fb7c7dd1f040074c9d7b51d74a8ea40d4df2fc7aa08b76b9e6c", size = 3932331 }, - { url = "https://files.pythonhosted.org/packages/31/d9/90409720277f88eb3ab72f9a32bfa54acdd97e94225df699e7713e850bd4/cryptography-44.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:9abcc2e083cbe8dde89124a47e5e53ec38751f0d7dfd36801008f316a127d7ba", size = 4165207 }, { url = "https://files.pythonhosted.org/packages/7f/df/8be88797f0a1cca6e255189a57bb49237402b1880d6e8721690c5603ac23/cryptography-44.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:d2436114e46b36d00f8b72ff57e598978b37399d2786fd39793c36c6d5cb1c64", size = 4017372 }, { url = "https://files.pythonhosted.org/packages/af/36/5ccc376f025a834e72b8e52e18746b927f34e4520487098e283a719c205e/cryptography-44.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a01956ddfa0a6790d594f5b34fc1bfa6098aca434696a03cfdbe469b8ed79285", size = 4239657 }, { url = "https://files.pythonhosted.org/packages/46/b0/f4f7d0d0bcfbc8dd6296c1449be326d04217c57afb8b2594f017eed95533/cryptography-44.0.0-cp39-abi3-win32.whl", hash = "sha256:eca27345e1214d1b9f9490d200f9db5a874479be914199194e746c893788d417", size = 2758672 }, @@ -784,6 +799,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ac/38/08cc303ddddc4b3d7c628c3039a61a3aae36c241ed01393d00c2fd663473/greenlet-3.1.1-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:411f015496fec93c1c8cd4e5238da364e1da7a124bcb293f085bf2860c32c6f6", size = 1142112 }, ] +[[package]] +name = "h11" +version = "0.14.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f5/38/3af3d3633a34a3316095b39c8e8fb4853a28a536e55d347bd8d8e9a14b03/h11-0.14.0.tar.gz", hash = "sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d", size = 100418 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/95/04/ff642e65ad6b90db43e668d70ffb6736436c7ce41fcc549f4e9472234127/h11-0.14.0-py3-none-any.whl", hash = "sha256:e3fe4ac4b851c468cc8363d500db52c2ead036020723024a109d37346efaa761", size = 58259 }, +] + [[package]] name = "honcho" version = "2.0.0" @@ -796,6 +820,34 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/48/1c/25631fc359955569e63f5446dbb7022c320edf9846cbe892ee5113433a7e/honcho-2.0.0-py3-none-any.whl", hash = "sha256:56dcd04fc72d362a4befb9303b1a1a812cba5da283526fbc6509be122918ddf3", size = 22093 }, ] +[[package]] +name = "httpcore" +version = "1.0.7" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6a/41/d7d0a89eb493922c37d343b607bc1b5da7f5be7e383740b4753ad8943e90/httpcore-1.0.7.tar.gz", hash = "sha256:8551cb62a169ec7162ac7be8d4817d561f60e08eaa485234898414bb5a8a0b4c", size = 85196 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/87/f5/72347bc88306acb359581ac4d52f23c0ef445b57157adedb9aee0cd689d2/httpcore-1.0.7-py3-none-any.whl", hash = "sha256:a3fff8f43dc260d5bd363d9f9cf1830fa3a458b332856f34282de498ed420edd", size = 78551 }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517 }, +] + [[package]] name = "identify" version = "2.6.3" @@ -1749,6 +1801,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/72/14/1c4107f0e625a4f95c5412b82f20ea05665c515d74e724898d4c3a8c7ac5/smtpdfix-0.5.2-py3-none-any.whl", hash = "sha256:85045723df6bb492af2ddbadfb0b8f8b5c52f69bb6fd4f6bca78c4f182d6c2b7", size = 17294 }, ] +[[package]] +name = "sniffio" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235 }, +] + [[package]] name = "snowballstemmer" version = "2.2.0"