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:
Jens L
2024-05-20 15:37:22 +02:00
committed by GitHub
parent 2e91b9d035
commit 44d7e81a93
20 changed files with 301 additions and 288 deletions

View File

@ -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)

View File

@ -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"

View File

@ -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"

View File

@ -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)

View File

@ -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())