diff --git a/README.rst b/README.rst index b5cfb6f7cb852048ef56ce33f11158535d9b24e7..cab317ccfd5fe64f084fd58234fe869ab1965fc6 100644 --- a/README.rst +++ b/README.rst @@ -17,6 +17,7 @@ Licence :: Copyright © 2020 Tom Teichler <tom.teichler@teckids.org> + Copyright © 2020 Dominik George <dominik.george@teckids.org> Licenced under the EUPL, version 1.2 or later diff --git a/aleksis/apps/ldap/apps.py b/aleksis/apps/ldap/apps.py index 88f49092f03fdfe5f69e5e873d3199145a6271d5..66bec2ebbbaf64bf823505a8dbb27b16c7d9e9df 100644 --- a/aleksis/apps/ldap/apps.py +++ b/aleksis/apps/ldap/apps.py @@ -3,7 +3,7 @@ from django.db.models.signals import post_save from aleksis.core.util.apps import AppConfig -from .util.ldap_sync import ldap_sync_from_user +from .util.ldap_sync import ldap_sync_from_user, update_constance_config_fields class LDAPConfig(AppConfig): name = "aleksis.apps.ldap" @@ -11,5 +11,8 @@ class LDAPConfig(AppConfig): def ready(self) -> None: super().ready() + + update_constance_config_fields() + User = get_user_model() post_save.connect(ldap_sync_from_user, sender=User) diff --git a/aleksis/apps/ldap/settings.py b/aleksis/apps/ldap/settings.py index ad56c32b134c878cac9915cb9bd3d054d839b6f0..5bc325fd0805cfbd36d4d7d95523eaca7f8c85b2 100644 --- a/aleksis/apps/ldap/settings.py +++ b/aleksis/apps/ldap/settings.py @@ -1,3 +1,4 @@ +from django.apps import apps from django.utils.translation import gettext_lazy as _ CONSTANCE_ADDITIONAL_FIELDS = { @@ -24,7 +25,6 @@ CONSTANCE_CONFIG = { "matching-fields-select", ), "ENABLE_LDAP_GROUP_SYNC": (True, _("Enable ldap group sync"), bool), - "LDAP_SYNC_CREATE_GROUPS": (True, _("Create non-existing groups"), bool), "LDAP_GROUP_SYNC_FIELD_SHORT_NAME": ("cn", _("Field for short name of group"), str), "LDAP_GROUP_SYNC_FIELD_NAME": ("cn", _("Field for name of group"), str), } @@ -34,7 +34,6 @@ CONSTANCE_CONFIG_FIELDSETS = { "LDAP_SYNC_ON_UPDATE", "LDAP_MATCHING_FIELDS", "ENABLE_LDAP_GROUP_SYNC", - "LDAP_SYNC_CREATE_GROUPS", "LDAP_GROUP_SYNC_FIELD_SHORT_NAME", "LDAP_GROUP_SYNC_FIELD_NAME", ), diff --git a/aleksis/apps/ldap/util/ldap_sync.py b/aleksis/apps/ldap/util/ldap_sync.py index 5312dafbf6ca7975f61ecfc7ceefe3f03a16c24d..b0866bbdd1c33c2a918202c77f279c263019858a 100644 --- a/aleksis/apps/ldap/util/ldap_sync.py +++ b/aleksis/apps/ldap/util/ldap_sync.py @@ -1,18 +1,77 @@ from django.apps import apps +from django.conf import settings from django.contrib.auth import get_user_model +from django.db.models import fields from constance import config +def setting_name_from_field(model, field): + """ Generate a constance setting name from a model field """ + + return "LDAP_ADDITIONAL_FIELD_%s_%s" % (model._meta.label, field.name) + + +def syncable_fields(model): + """ Collect all fields that can be synced on a model """ + + return [field for field in model._meta.fields if field.editable and not field.auto_created] + + +def from_ldap(value, field): + """ Convert an LDAP value to the Python type of the target field + + This conversion is prone to error because LDAP deliberately breaks + standards to cope with ASN.1 limitations. + """ + + from ldapdb.models.fields import datetime_from_ldap # noqa + + # Pre-convert DateTimeField and DateField due to ISO 8601 limitations in RFC 4517 + if type(field) in (fields.DateField, fields.DateTimeField): + # Be opportunistic, but keep old value if conversion fails + value = datetime_from_ldap(value) or value + + # Finally, use field's conversion method as default + return field.to_python(value) + + +def update_constance_config_fields(): + """ Auto-generate sync field settings from models """ + + Person = apps.get_model("core", "Person") + for model in (Person,): + # Collect fields that are matchable + setting_names = [] + for field in syncable_fields(model): + setting_name = setting_name_from_field(model, field) + setting_desc = field.verbose_name + + settings.CONSTANCE_CONFIG[setting_name] = ("", setting_desc, str) + setting_names.append(setting_name) + + # Add separate constance section if settings were generated + if setting_names: + fieldset_name = "LDAP-Sync: Additional fields for %s" % model._meta.verbose_name + settings.CONSTANCE_CONFIG_FIELDSETS[fieldset_name] = setting_names + + def ldap_sync_from_user(sender, instance, created, raw, using, update_fields, **kwargs): """ Synchronise Person meta-data and groups from ldap_user on User update. """ + # Semaphore to guard recursive saves within this signal + if getattr(instance, "_skip_signal", False): + return + instance._skip_signal = True + Person = apps.get_model("core", "Person") Group = apps.get_model("core", "Group") if config.ENABLE_LDAP_SYNC and (created or config.LDAP_SYNC_ON_UPDATE) and hasattr(instance, "ldap_user"): # Check if there is an existing person connected to the user. - if not Person.objects.filter(user=instance).exists(): + if Person.objects.filter(user=instance).exists(): + person = instance.person + else: # Build filter criteria depending on config matches = {} if "-email" in config.LDAP_MATCHING_FIELDS: @@ -28,7 +87,16 @@ def ldap_sync_from_user(sender, instance, created, raw, using, update_fields, ** return person.user = instance - person.save() + + # Synchronise additional fields if enabled + for field in syncable_fields(Person): + setting_name = setting_name_from_field(Person, field) + + # Try sync if constance setting for this field is non-empty + ldap_field = getattr(config, setting_name, "").lower() + if ldap_field and ldap_field in instance.ldap_user.attrs.data: + setattr(person, field.name, + from_ldap(instance.ldap_user.attrs.data[ldap_field][0], field)) if config.ENABLE_LDAP_GROUP_SYNC: # Resolve Group objects from LDAP group objects @@ -47,4 +115,14 @@ def ldap_sync_from_user(sender, instance, created, raw, using, update_fields, ** group_objects.append(group) # Replace linked groups of logged-in user completely - instance.person.member_of.set(group_objects) + person.member_of.set(group_objects) + + try: + person.save() + except Exception: + # Exceptions here are silenced because the synchronisation is optional + # FIXME throw warning to user instead + pass + + # Remove semaphore + del instance._skip_signal diff --git a/pyproject.toml b/pyproject.toml index 0c61cba4435b19004dd9bdeab90c0f2381f51d20..697302fd5c33c7e01fa7d809626d589003de3131 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,6 +20,7 @@ classifiers = [ [tool.poetry.dependencies] python = "^3.7" +django-ldapdb = "^1.4.0" AlekSIS = { path = "../../.." } [build-system]