providers/scim: optimize PropertyMapping fetching and execution (#9689)
* add helper to mass-compile and re-use mappings Signed-off-by: Jens Langhammer <jens@goauthentik.io> * implement for scim Signed-off-by: Jens Langhammer <jens@goauthentik.io> * actually make it even simpler Signed-off-by: Jens Langhammer <jens@goauthentik.io> * migrate google Signed-off-by: Jens Langhammer <jens@goauthentik.io> * migrate microsoft too Signed-off-by: Jens Langhammer <jens@goauthentik.io> * remove sleeps in tests with freezegun Signed-off-by: Jens Langhammer <jens@goauthentik.io> * migrate ldap to propertymapping helper Signed-off-by: Jens Langhammer <jens@goauthentik.io> * move mapper to generic sync Signed-off-by: Jens Langhammer <jens@goauthentik.io> * fix Signed-off-by: Jens Langhammer <jens@goauthentik.io> * apparently that doesn't work Signed-off-by: Jens Langhammer <jens@goauthentik.io> * forgot a sleep Signed-off-by: Jens Langhammer <jens@goauthentik.io> * backport fixes from #9783 Signed-off-by: Jens Langhammer <jens@goauthentik.io> --------- Signed-off-by: Jens Langhammer <jens@goauthentik.io>
This commit is contained in:
		@ -5,7 +5,6 @@ from typing import Any
 | 
			
		||||
 | 
			
		||||
from django.conf import settings
 | 
			
		||||
from django.db.models.base import Model
 | 
			
		||||
from django.db.models.query import QuerySet
 | 
			
		||||
from ldap3 import DEREF_ALWAYS, SUBTREE, Connection
 | 
			
		||||
from structlog.stdlib import BoundLogger, get_logger
 | 
			
		||||
 | 
			
		||||
@ -16,8 +15,11 @@ from authentik.core.expression.exceptions import (
 | 
			
		||||
from authentik.events.models import Event, EventAction
 | 
			
		||||
from authentik.lib.config import CONFIG, set_path_in_dict
 | 
			
		||||
from authentik.lib.merge import MERGE_LIST_UNIQUE
 | 
			
		||||
from authentik.lib.sync.mapper import PropertyMappingManager
 | 
			
		||||
from authentik.lib.sync.outgoing.exceptions import StopSync
 | 
			
		||||
from authentik.lib.utils.errors import exception_to_string
 | 
			
		||||
from authentik.sources.ldap.auth import LDAP_DISTINGUISHED_NAME
 | 
			
		||||
from authentik.sources.ldap.models import LDAPPropertyMapping, LDAPSource
 | 
			
		||||
from authentik.sources.ldap.models import LDAPSource
 | 
			
		||||
 | 
			
		||||
LDAP_UNIQUENESS = "ldap_uniq"
 | 
			
		||||
 | 
			
		||||
@ -38,6 +40,7 @@ class BaseLDAPSynchronizer:
 | 
			
		||||
    _logger: BoundLogger
 | 
			
		||||
    _connection: Connection
 | 
			
		||||
    _messages: list[str]
 | 
			
		||||
    mapper: PropertyMappingManager
 | 
			
		||||
 | 
			
		||||
    def __init__(self, source: LDAPSource):
 | 
			
		||||
        self._source = source
 | 
			
		||||
@ -139,52 +142,47 @@ class BaseLDAPSynchronizer:
 | 
			
		||||
 | 
			
		||||
    def build_user_properties(self, user_dn: str, **kwargs) -> dict[str, Any]:
 | 
			
		||||
        """Build attributes for User object based on property mappings."""
 | 
			
		||||
        props = self._build_object_properties(user_dn, self._source.property_mappings, **kwargs)
 | 
			
		||||
        props = self._build_object_properties(user_dn, **kwargs)
 | 
			
		||||
        props.setdefault("path", self._source.get_user_path())
 | 
			
		||||
        return props
 | 
			
		||||
 | 
			
		||||
    def build_group_properties(self, group_dn: str, **kwargs) -> dict[str, Any]:
 | 
			
		||||
        """Build attributes for Group object based on property mappings."""
 | 
			
		||||
        return self._build_object_properties(
 | 
			
		||||
            group_dn, self._source.property_mappings_group, **kwargs
 | 
			
		||||
        )
 | 
			
		||||
        return self._build_object_properties(group_dn, **kwargs)
 | 
			
		||||
 | 
			
		||||
    def _build_object_properties(
 | 
			
		||||
        self, object_dn: str, mappings: QuerySet, **kwargs
 | 
			
		||||
    ) -> dict[str, dict[Any, Any]]:
 | 
			
		||||
    def _build_object_properties(self, object_dn: str, **kwargs) -> dict[str, dict[Any, Any]]:
 | 
			
		||||
        properties = {"attributes": {}}
 | 
			
		||||
        for mapping in mappings.all().select_subclasses():
 | 
			
		||||
            if not isinstance(mapping, LDAPPropertyMapping):
 | 
			
		||||
                continue
 | 
			
		||||
            mapping: LDAPPropertyMapping
 | 
			
		||||
            try:
 | 
			
		||||
                value = mapping.evaluate(
 | 
			
		||||
                    user=None, request=None, ldap=kwargs, dn=object_dn, source=self._source
 | 
			
		||||
                )
 | 
			
		||||
                if value is None:
 | 
			
		||||
                    self._logger.warning("property mapping returned None", mapping=mapping)
 | 
			
		||||
                    continue
 | 
			
		||||
                if isinstance(value, (bytes)):
 | 
			
		||||
                    self._logger.warning("property mapping returned bytes", mapping=mapping)
 | 
			
		||||
                    continue
 | 
			
		||||
                object_field = mapping.object_field
 | 
			
		||||
                if object_field.startswith("attributes."):
 | 
			
		||||
                    # Because returning a list might desired, we can't
 | 
			
		||||
                    # rely on flatten here. Instead, just save the result as-is
 | 
			
		||||
                    set_path_in_dict(properties, object_field, value)
 | 
			
		||||
                else:
 | 
			
		||||
                    properties[object_field] = flatten(value)
 | 
			
		||||
            except SkipObjectException as exc:
 | 
			
		||||
                raise exc from exc
 | 
			
		||||
            except PropertyMappingExpressionException as exc:
 | 
			
		||||
                Event.new(
 | 
			
		||||
                    EventAction.CONFIGURATION_ERROR,
 | 
			
		||||
                    message=f"Failed to evaluate property-mapping: '{mapping.name}'",
 | 
			
		||||
                    source=self._source,
 | 
			
		||||
                    mapping=mapping,
 | 
			
		||||
                ).save()
 | 
			
		||||
                self._logger.warning("Mapping failed to evaluate", exc=exc, mapping=mapping)
 | 
			
		||||
                continue
 | 
			
		||||
        try:
 | 
			
		||||
            for value, mapping in self.mapper.iter_eval(
 | 
			
		||||
                user=None,
 | 
			
		||||
                request=None,
 | 
			
		||||
                return_mapping=True,
 | 
			
		||||
                ldap=kwargs,
 | 
			
		||||
                dn=object_dn,
 | 
			
		||||
                source=self._source,
 | 
			
		||||
            ):
 | 
			
		||||
                try:
 | 
			
		||||
                    if isinstance(value, (bytes)):
 | 
			
		||||
                        self._logger.warning("property mapping returned bytes", mapping=mapping)
 | 
			
		||||
                        continue
 | 
			
		||||
                    object_field = mapping.object_field
 | 
			
		||||
                    if object_field.startswith("attributes."):
 | 
			
		||||
                        # Because returning a list might desired, we can't
 | 
			
		||||
                        # rely on flatten here. Instead, just save the result as-is
 | 
			
		||||
                        set_path_in_dict(properties, object_field, value)
 | 
			
		||||
                    else:
 | 
			
		||||
                        properties[object_field] = flatten(value)
 | 
			
		||||
                except SkipObjectException as exc:
 | 
			
		||||
                    raise exc from exc
 | 
			
		||||
        except PropertyMappingExpressionException as exc:
 | 
			
		||||
            # Value error can be raised when assigning invalid data to an attribute
 | 
			
		||||
            Event.new(
 | 
			
		||||
                EventAction.CONFIGURATION_ERROR,
 | 
			
		||||
                message=f"Failed to evaluate property-mapping {exception_to_string(exc)}",
 | 
			
		||||
                mapping=exc.mapping,
 | 
			
		||||
            ).save()
 | 
			
		||||
            self._logger.warning("Mapping failed to evaluate", exc=exc, mapping=exc.mapping)
 | 
			
		||||
            raise StopSync(exc, None, exc.mapping) from exc
 | 
			
		||||
        if self._source.object_uniqueness_field in kwargs:
 | 
			
		||||
            properties["attributes"][LDAP_UNIQUENESS] = flatten(
 | 
			
		||||
                kwargs.get(self._source.object_uniqueness_field)
 | 
			
		||||
 | 
			
		||||
@ -9,12 +9,22 @@ from ldap3 import ALL_ATTRIBUTES, ALL_OPERATIONAL_ATTRIBUTES, SUBTREE
 | 
			
		||||
from authentik.core.expression.exceptions import SkipObjectException
 | 
			
		||||
from authentik.core.models import Group
 | 
			
		||||
from authentik.events.models import Event, EventAction
 | 
			
		||||
from authentik.lib.sync.mapper import PropertyMappingManager
 | 
			
		||||
from authentik.sources.ldap.models import LDAPPropertyMapping, LDAPSource
 | 
			
		||||
from authentik.sources.ldap.sync.base import LDAP_UNIQUENESS, BaseLDAPSynchronizer, flatten
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class GroupLDAPSynchronizer(BaseLDAPSynchronizer):
 | 
			
		||||
    """Sync LDAP Users and groups into authentik"""
 | 
			
		||||
 | 
			
		||||
    def __init__(self, source: LDAPSource):
 | 
			
		||||
        super().__init__(source)
 | 
			
		||||
        self.mapper = PropertyMappingManager(
 | 
			
		||||
            self._source.property_mappings_group.all().order_by("name").select_subclasses(),
 | 
			
		||||
            LDAPPropertyMapping,
 | 
			
		||||
            ["ldap", "dn", "source"],
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
    @staticmethod
 | 
			
		||||
    def name() -> str:
 | 
			
		||||
        return "groups"
 | 
			
		||||
 | 
			
		||||
@ -9,6 +9,8 @@ from ldap3 import ALL_ATTRIBUTES, ALL_OPERATIONAL_ATTRIBUTES, SUBTREE
 | 
			
		||||
from authentik.core.expression.exceptions import SkipObjectException
 | 
			
		||||
from authentik.core.models import User
 | 
			
		||||
from authentik.events.models import Event, EventAction
 | 
			
		||||
from authentik.lib.sync.mapper import PropertyMappingManager
 | 
			
		||||
from authentik.sources.ldap.models import LDAPPropertyMapping, LDAPSource
 | 
			
		||||
from authentik.sources.ldap.sync.base import LDAP_UNIQUENESS, BaseLDAPSynchronizer, flatten
 | 
			
		||||
from authentik.sources.ldap.sync.vendor.freeipa import FreeIPA
 | 
			
		||||
from authentik.sources.ldap.sync.vendor.ms_ad import MicrosoftActiveDirectory
 | 
			
		||||
@ -17,6 +19,14 @@ from authentik.sources.ldap.sync.vendor.ms_ad import MicrosoftActiveDirectory
 | 
			
		||||
class UserLDAPSynchronizer(BaseLDAPSynchronizer):
 | 
			
		||||
    """Sync LDAP Users into authentik"""
 | 
			
		||||
 | 
			
		||||
    def __init__(self, source: LDAPSource):
 | 
			
		||||
        super().__init__(source)
 | 
			
		||||
        self.mapper = PropertyMappingManager(
 | 
			
		||||
            self._source.property_mappings.all().order_by("name").select_subclasses(),
 | 
			
		||||
            LDAPPropertyMapping,
 | 
			
		||||
            ["ldap", "dn", "source"],
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
    @staticmethod
 | 
			
		||||
    def name() -> str:
 | 
			
		||||
        return "users"
 | 
			
		||||
 | 
			
		||||
@ -12,6 +12,7 @@ from authentik.events.models import SystemTask as DBSystemTask
 | 
			
		||||
from authentik.events.models import TaskStatus
 | 
			
		||||
from authentik.events.system_tasks import SystemTask
 | 
			
		||||
from authentik.lib.config import CONFIG
 | 
			
		||||
from authentik.lib.sync.outgoing.exceptions import StopSync
 | 
			
		||||
from authentik.lib.utils.errors import exception_to_string
 | 
			
		||||
from authentik.lib.utils.reflection import class_to_path, path_to_class
 | 
			
		||||
from authentik.root.celery import CELERY_APP
 | 
			
		||||
@ -138,7 +139,7 @@ def ldap_sync(self: SystemTask, source_pk: str, sync_class: str, page_cache_key:
 | 
			
		||||
            *messages,
 | 
			
		||||
        )
 | 
			
		||||
        cache.delete(page_cache_key)
 | 
			
		||||
    except LDAPException as exc:
 | 
			
		||||
    except (LDAPException, StopSync) as exc:
 | 
			
		||||
        # No explicit event is created here as .set_status with an error will do that
 | 
			
		||||
        LOGGER.warning(exception_to_string(exc))
 | 
			
		||||
        self.set_error(exc)
 | 
			
		||||
 | 
			
		||||
@ -11,6 +11,7 @@ from authentik.core.tests.utils import create_test_admin_user
 | 
			
		||||
from authentik.events.models import Event, EventAction, SystemTask
 | 
			
		||||
from authentik.events.system_tasks import TaskStatus
 | 
			
		||||
from authentik.lib.generators import generate_id, generate_key
 | 
			
		||||
from authentik.lib.sync.outgoing.exceptions import StopSync
 | 
			
		||||
from authentik.lib.utils.reflection import class_to_path
 | 
			
		||||
from authentik.sources.ldap.models import LDAPPropertyMapping, LDAPSource
 | 
			
		||||
from authentik.sources.ldap.sync.groups import GroupLDAPSynchronizer
 | 
			
		||||
@ -63,12 +64,13 @@ class LDAPSyncTests(TestCase):
 | 
			
		||||
        connection = MagicMock(return_value=mock_ad_connection(LDAP_PASSWORD))
 | 
			
		||||
        with patch("authentik.sources.ldap.models.LDAPSource.connection", connection):
 | 
			
		||||
            user_sync = UserLDAPSynchronizer(self.source)
 | 
			
		||||
            user_sync.sync_full()
 | 
			
		||||
            with self.assertRaises(StopSync):
 | 
			
		||||
                user_sync.sync_full()
 | 
			
		||||
            self.assertFalse(User.objects.filter(username="user0_sn").exists())
 | 
			
		||||
            self.assertFalse(User.objects.filter(username="user1_sn").exists())
 | 
			
		||||
        events = Event.objects.filter(
 | 
			
		||||
            action=EventAction.CONFIGURATION_ERROR,
 | 
			
		||||
            context__message="Failed to evaluate property-mapping: 'name'",
 | 
			
		||||
            context__mapping__pk=mapping.pk.hex,
 | 
			
		||||
        )
 | 
			
		||||
        self.assertTrue(events.exists())
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
		Reference in New Issue
	
	Block a user