From 14dc4207479c1de55390c1aa978e50cb32798aa8 Mon Sep 17 00:00:00 2001 From: Jens Langhammer Date: Thu, 4 Feb 2021 20:06:42 +0100 Subject: [PATCH] sources/ldap: rewrite group membership syncing --- .../providers/saml/processors/assertion.py | 3 +- authentik/root/settings.py | 1 - authentik/sources/ldap/api.py | 2 +- authentik/sources/ldap/auth.py | 5 +- authentik/sources/ldap/forms.py | 4 +- .../migrations/0009_auto_20210204_1834.py | 24 +++ authentik/sources/ldap/models.py | 4 +- authentik/sources/ldap/password.py | 9 +- authentik/sources/ldap/sync.py | 194 ------------------ authentik/sources/ldap/sync/__init__.py | 0 authentik/sources/ldap/sync/base.py | 35 ++++ authentik/sources/ldap/sync/groups.py | 54 +++++ authentik/sources/ldap/sync/membership.py | 63 ++++++ authentik/sources/ldap/sync/users.py | 97 +++++++++ authentik/sources/ldap/tasks.py | 19 +- authentik/sources/ldap/tests/test_auth.py | 10 +- authentik/sources/ldap/tests/test_password.py | 4 +- authentik/sources/ldap/tests/test_sync.py | 19 +- authentik/sources/ldap/tests/utils.py | 6 +- swagger.yaml | 6 +- .../sources/active-directory/index.md | 2 +- 21 files changed, 326 insertions(+), 235 deletions(-) create mode 100644 authentik/sources/ldap/migrations/0009_auto_20210204_1834.py delete mode 100644 authentik/sources/ldap/sync.py create mode 100644 authentik/sources/ldap/sync/__init__.py create mode 100644 authentik/sources/ldap/sync/base.py create mode 100644 authentik/sources/ldap/sync/groups.py create mode 100644 authentik/sources/ldap/sync/membership.py create mode 100644 authentik/sources/ldap/sync/users.py diff --git a/authentik/providers/saml/processors/assertion.py b/authentik/providers/saml/processors/assertion.py index a14c27cdb..428472519 100644 --- a/authentik/providers/saml/processors/assertion.py +++ b/authentik/providers/saml/processors/assertion.py @@ -15,6 +15,7 @@ from authentik.providers.saml.models import SAMLPropertyMapping, SAMLProvider from authentik.providers.saml.processors.request_parser import AuthNRequest from authentik.providers.saml.utils import get_random_id from authentik.providers.saml.utils.time import get_time_string +from authentik.sources.ldap.auth import LDAP_DISTINGUISHED_NAME from authentik.sources.saml.exceptions import UnsupportedNameIDFormat from authentik.sources.saml.processors.constants import ( DIGEST_ALGORITHM_TRANSLATION_MAP, @@ -173,7 +174,7 @@ class AssertionProcessor: if name_id.attrib["Format"] == SAML_NAME_ID_FORMAT_X509: # This attribute is statically set by the LDAP source name_id.text = self.http_request.user.attributes.get( - "distinguishedName", persistent + LDAP_DISTINGUISHED_NAME, persistent ) return name_id if name_id.attrib["Format"] == SAML_NAME_ID_FORMAT_WINDOWS: diff --git a/authentik/root/settings.py b/authentik/root/settings.py index 992f5f6c3..665465782 100644 --- a/authentik/root/settings.py +++ b/authentik/root/settings.py @@ -371,7 +371,6 @@ structlog.configure_once( structlog.processors.format_exc_info, structlog.stdlib.ProcessorFormatter.wrap_for_formatter, ], - context_class=structlog.threadlocal.wrap_dict(dict), logger_factory=structlog.stdlib.LoggerFactory(), wrapper_class=structlog.make_filtering_bound_logger( getattr(logging, LOG_LEVEL, logging.WARNING) diff --git a/authentik/sources/ldap/api.py b/authentik/sources/ldap/api.py index b5daee234..60fb450b0 100644 --- a/authentik/sources/ldap/api.py +++ b/authentik/sources/ldap/api.py @@ -22,7 +22,7 @@ class LDAPSourceSerializer(ModelSerializer, MetaNameSerializer): "additional_group_dn", "user_object_filter", "group_object_filter", - "user_group_membership_field", + "group_membership_field", "object_uniqueness_field", "sync_users", "sync_users_password", diff --git a/authentik/sources/ldap/auth.py b/authentik/sources/ldap/auth.py index f10bf169c..f2dcdf839 100644 --- a/authentik/sources/ldap/auth.py +++ b/authentik/sources/ldap/auth.py @@ -10,6 +10,7 @@ from authentik.core.models import User from authentik.sources.ldap.models import LDAPSource LOGGER = get_logger() +LDAP_DISTINGUISHED_NAME = "distinguishedName" class LDAPBackend(ModelBackend): @@ -35,7 +36,7 @@ class LDAPBackend(ModelBackend): if not users.exists(): return None user: User = users.first() - if "distinguishedName" not in user.attributes: + if LDAP_DISTINGUISHED_NAME not in user.attributes: LOGGER.debug( "User doesn't have DN set, assuming not LDAP imported.", user=user ) @@ -63,7 +64,7 @@ class LDAPBackend(ModelBackend): try: temp_connection = ldap3.Connection( source.connection.server, - user=user.attributes.get("distinguishedName"), + user=user.attributes.get(LDAP_DISTINGUISHED_NAME), password=password, raise_exceptions=True, ) diff --git a/authentik/sources/ldap/forms.py b/authentik/sources/ldap/forms.py index d78bf6a65..e89e29d1f 100644 --- a/authentik/sources/ldap/forms.py +++ b/authentik/sources/ldap/forms.py @@ -37,7 +37,7 @@ class LDAPSourceForm(forms.ModelForm): "additional_group_dn", "user_object_filter", "group_object_filter", - "user_group_membership_field", + "group_membership_field", "object_uniqueness_field", "sync_parent_group", ] @@ -51,7 +51,7 @@ class LDAPSourceForm(forms.ModelForm): "additional_group_dn": forms.TextInput(), "user_object_filter": forms.TextInput(), "group_object_filter": forms.TextInput(), - "user_group_membership_field": forms.TextInput(), + "group_membership_field": forms.TextInput(), "object_uniqueness_field": forms.TextInput(), } diff --git a/authentik/sources/ldap/migrations/0009_auto_20210204_1834.py b/authentik/sources/ldap/migrations/0009_auto_20210204_1834.py new file mode 100644 index 000000000..77718f323 --- /dev/null +++ b/authentik/sources/ldap/migrations/0009_auto_20210204_1834.py @@ -0,0 +1,24 @@ +# Generated by Django 3.1.6 on 2021-02-04 18:34 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("authentik_sources_ldap", "0008_managed"), + ] + + operations = [ + migrations.RemoveField( + model_name="ldapsource", + name="user_group_membership_field", + ), + migrations.AddField( + model_name="ldapsource", + name="group_membership_field", + field=models.TextField( + default="member", help_text="Field which contains members of a group." + ), + ), + ] diff --git a/authentik/sources/ldap/models.py b/authentik/sources/ldap/models.py index 83319ec30..6dfa45005 100644 --- a/authentik/sources/ldap/models.py +++ b/authentik/sources/ldap/models.py @@ -41,8 +41,8 @@ class LDAPSource(Source): default="(objectCategory=Person)", help_text=_("Consider Objects matching this filter to be Users."), ) - user_group_membership_field = models.TextField( - default="memberOf", help_text=_("Field which contains Groups of user.") + group_membership_field = models.TextField( + default="member", help_text=_("Field which contains members of a group.") ) group_object_filter = models.TextField( default="(objectCategory=Group)", diff --git a/authentik/sources/ldap/password.py b/authentik/sources/ldap/password.py index 0769c6040..fc26a0431 100644 --- a/authentik/sources/ldap/password.py +++ b/authentik/sources/ldap/password.py @@ -8,6 +8,7 @@ import ldap3.core.exceptions from structlog.stdlib import get_logger from authentik.core.models import User +from authentik.sources.ldap.auth import LDAP_DISTINGUISHED_NAME from authentik.sources.ldap.models import LDAPSource LOGGER = get_logger() @@ -74,9 +75,9 @@ class LDAPPasswordChanger: def change_password(self, user: User, password: str): """Change user's password""" - user_dn = user.attributes.get("distinguishedName", None) + user_dn = user.attributes.get(LDAP_DISTINGUISHED_NAME, None) if not user_dn: - raise AttributeError("User has no distinguishedName set.") + raise AttributeError(f"User has no {LDAP_DISTINGUISHED_NAME} set.") self._source.connection.extend.microsoft.modify_password(user_dn, password) def _ad_check_password_existing(self, password: str, user_dn: str) -> bool: @@ -117,9 +118,9 @@ class LDAPPasswordChanger: """ if user: # Check if password contains sAMAccountName or displayNames - if "distinguishedName" in user.attributes: + if LDAP_DISTINGUISHED_NAME in user.attributes: existing_user_check = self._ad_check_password_existing( - password, user.attributes.get("distinguishedName") + password, user.attributes.get(LDAP_DISTINGUISHED_NAME) ) if not existing_user_check: LOGGER.debug("Password failed name check", user=user) diff --git a/authentik/sources/ldap/sync.py b/authentik/sources/ldap/sync.py deleted file mode 100644 index 63d1f21e8..000000000 --- a/authentik/sources/ldap/sync.py +++ /dev/null @@ -1,194 +0,0 @@ -"""Sync LDAP Users and groups into authentik""" -from typing import Any, Dict - -import ldap3 -import ldap3.core.exceptions -from django.db.utils import IntegrityError -from structlog.stdlib import get_logger - -from authentik.core.exceptions import PropertyMappingExpressionException -from authentik.core.models import Group, User -from authentik.sources.ldap.models import LDAPPropertyMapping, LDAPSource - -LOGGER = get_logger() - - -class LDAPSynchronizer: - """Sync LDAP Users and groups into authentik""" - - _source: LDAPSource - - def __init__(self, source: LDAPSource): - self._source = source - - @property - def base_dn_users(self) -> str: - """Shortcut to get full base_dn for user lookups""" - if self._source.additional_user_dn: - return f"{self._source.additional_user_dn},{self._source.base_dn}" - return self._source.base_dn - - @property - def base_dn_groups(self) -> str: - """Shortcut to get full base_dn for group lookups""" - if self._source.additional_group_dn: - return f"{self._source.additional_group_dn},{self._source.base_dn}" - return self._source.base_dn - - def sync_groups(self) -> int: - """Iterate over all LDAP Groups and create authentik_core.Group instances""" - if not self._source.sync_groups: - LOGGER.warning("Group syncing is disabled for this Source") - return -1 - groups = self._source.connection.extend.standard.paged_search( - search_base=self.base_dn_groups, - search_filter=self._source.group_object_filter, - search_scope=ldap3.SUBTREE, - attributes=[ldap3.ALL_ATTRIBUTES, ldap3.ALL_OPERATIONAL_ATTRIBUTES], - ) - group_count = 0 - for group in groups: - attributes = group.get("attributes", {}) - if self._source.object_uniqueness_field not in attributes: - LOGGER.warning( - "Cannot find uniqueness Field in attributes", user=attributes.keys() - ) - continue - uniq = attributes[self._source.object_uniqueness_field] - _, created = Group.objects.update_or_create( - attributes__ldap_uniq=uniq, - parent=self._source.sync_parent_group, - defaults={ - "name": attributes.get("name", ""), - "attributes": { - "ldap_uniq": uniq, - "distinguishedName": attributes.get("distinguishedName"), - }, - }, - ) - LOGGER.debug( - "Synced group", group=attributes.get("name", ""), created=created - ) - group_count += 1 - return group_count - - def sync_users(self) -> int: - """Iterate over all LDAP Users and create authentik_core.User instances""" - if not self._source.sync_users: - LOGGER.warning("User syncing is disabled for this Source") - return -1 - users = self._source.connection.extend.standard.paged_search( - search_base=self.base_dn_users, - search_filter=self._source.user_object_filter, - search_scope=ldap3.SUBTREE, - attributes=[ldap3.ALL_ATTRIBUTES, ldap3.ALL_OPERATIONAL_ATTRIBUTES], - ) - user_count = 0 - for user in users: - attributes = user.get("attributes", {}) - if self._source.object_uniqueness_field not in attributes: - LOGGER.warning( - "Cannot find uniqueness Field in attributes", user=user.keys() - ) - continue - uniq = attributes[self._source.object_uniqueness_field] - try: - defaults = self._build_object_properties(attributes) - user, created = User.objects.update_or_create( - attributes__ldap_uniq=uniq, - defaults=defaults, - ) - except IntegrityError as exc: - LOGGER.warning("Failed to create user", exc=exc) - LOGGER.warning( - ( - "To merge new User with existing user, set the User's " - f"Attribute 'ldap_uniq' to '{uniq}'" - ) - ) - else: - if created: - user.set_unusable_password() - user.save() - LOGGER.debug( - "Synced User", user=attributes.get("name", ""), created=created - ) - user_count += 1 - return user_count - - def sync_membership(self): - """Iterate over all Users and assign Groups using memberOf Field""" - users = self._source.connection.extend.standard.paged_search( - search_base=self.base_dn_users, - search_filter=self._source.user_object_filter, - search_scope=ldap3.SUBTREE, - attributes=[ - self._source.user_group_membership_field, - self._source.object_uniqueness_field, - ], - ) - group_cache: Dict[str, Group] = {} - for user in users: - member_of = user.get("attributes", {}).get( - self._source.user_group_membership_field, [] - ) - uniq = user.get("attributes", {}).get( - self._source.object_uniqueness_field, [] - ) - for group_dn in member_of: - # Check if group_dn is within our base_dn_groups, and skip if not - if not group_dn.endswith(self.base_dn_groups): - continue - # Check if we fetched the group already, and if not cache it for later - if group_dn not in group_cache: - groups = Group.objects.filter( - attributes__distinguishedName=group_dn - ) - if not groups.exists(): - LOGGER.warning( - "Group does not exist in our DB yet, run sync_groups first.", - group=group_dn, - ) - return - group_cache[group_dn] = groups.first() - group = group_cache[group_dn] - users = User.objects.filter(attributes__ldap_uniq=uniq) - group.users.add(*list(users)) - # Now that all users are added, lets write everything - for _, group in group_cache.items(): - group.save() - LOGGER.debug("Successfully updated group membership") - - def _build_object_properties( - self, attributes: Dict[str, Any] - ) -> Dict[str, Dict[Any, Any]]: - properties = {"attributes": {}} - for mapping in self._source.property_mappings.all().select_subclasses(): - if not isinstance(mapping, LDAPPropertyMapping): - continue - mapping: LDAPPropertyMapping - try: - value = mapping.evaluate(user=None, request=None, ldap=attributes) - if value is None: - continue - object_field = mapping.object_field - if object_field.startswith("attributes."): - properties["attributes"][ - object_field.replace("attributes.", "") - ] = value - else: - properties[object_field] = value - except PropertyMappingExpressionException as exc: - LOGGER.warning("Mapping failed to evaluate", exc=exc, mapping=mapping) - continue - if self._source.object_uniqueness_field in attributes: - properties["attributes"]["ldap_uniq"] = attributes.get( - self._source.object_uniqueness_field - ) - distinguished_name = attributes.get("distinguishedName", attributes.get("dn")) - if not distinguished_name: - raise IntegrityError( - "Object does not have a distinguishedName or dn field." - ) - properties["attributes"]["distinguishedName"] = distinguished_name - return properties diff --git a/authentik/sources/ldap/sync/__init__.py b/authentik/sources/ldap/sync/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/authentik/sources/ldap/sync/base.py b/authentik/sources/ldap/sync/base.py new file mode 100644 index 000000000..501d5f956 --- /dev/null +++ b/authentik/sources/ldap/sync/base.py @@ -0,0 +1,35 @@ +"""Sync LDAP Users and groups into authentik""" +from structlog.stdlib import BoundLogger, get_logger + +from authentik.sources.ldap.models import LDAPSource + +LDAP_UNIQUENESS = "ldap_uniq" + + +class BaseLDAPSynchronizer: + """Sync LDAP Users and groups into authentik""" + + _source: LDAPSource + _logger: BoundLogger + + def __init__(self, source: LDAPSource): + self._source = source + self._logger = get_logger().bind(source=source) + + @property + def base_dn_users(self) -> str: + """Shortcut to get full base_dn for user lookups""" + if self._source.additional_user_dn: + return f"{self._source.additional_user_dn},{self._source.base_dn}" + return self._source.base_dn + + @property + def base_dn_groups(self) -> str: + """Shortcut to get full base_dn for group lookups""" + if self._source.additional_group_dn: + return f"{self._source.additional_group_dn},{self._source.base_dn}" + return self._source.base_dn + + def sync(self): + """Sync function, implemented in subclass""" + raise NotImplementedError() diff --git a/authentik/sources/ldap/sync/groups.py b/authentik/sources/ldap/sync/groups.py new file mode 100644 index 000000000..05a8df647 --- /dev/null +++ b/authentik/sources/ldap/sync/groups.py @@ -0,0 +1,54 @@ +"""Sync LDAP Users and groups into authentik""" +import ldap3 +import ldap3.core.exceptions + +from authentik.core.models import Group +from authentik.sources.ldap.auth import LDAP_DISTINGUISHED_NAME +from authentik.sources.ldap.sync.base import LDAP_UNIQUENESS, BaseLDAPSynchronizer + + +class GroupLDAPSynchronizer(BaseLDAPSynchronizer): + """Sync LDAP Users and groups into authentik""" + + def sync(self) -> int: + """Iterate over all LDAP Groups and create authentik_core.Group instances""" + if not self._source.sync_groups: + self._logger.warning("Group syncing is disabled for this Source") + return -1 + groups = self._source.connection.extend.standard.paged_search( + search_base=self.base_dn_groups, + search_filter=self._source.group_object_filter, + search_scope=ldap3.SUBTREE, + attributes=[ldap3.ALL_ATTRIBUTES, ldap3.ALL_OPERATIONAL_ATTRIBUTES], + ) + group_count = 0 + for group in groups: + attributes = group.get("attributes", {}) + if self._source.object_uniqueness_field not in attributes: + self._logger.warning( + "Cannot find uniqueness Field in attributes", + attributes=attributes.keys(), + dn=attributes.get(LDAP_DISTINGUISHED_NAME, ""), + ) + continue + uniq = attributes[self._source.object_uniqueness_field] + _, created = Group.objects.update_or_create( + **{ + f"attributes__{LDAP_UNIQUENESS}": uniq, + "parent": self._source.sync_parent_group, + "defaults": { + "name": attributes.get("name", ""), + "attributes": { + LDAP_UNIQUENESS: uniq, + LDAP_DISTINGUISHED_NAME: attributes.get( + "distinguishedName" + ), + }, + }, + } + ) + self._logger.debug( + "Synced group", group=attributes.get("name", ""), created=created + ) + group_count += 1 + return group_count diff --git a/authentik/sources/ldap/sync/membership.py b/authentik/sources/ldap/sync/membership.py new file mode 100644 index 000000000..7fd7e9a1a --- /dev/null +++ b/authentik/sources/ldap/sync/membership.py @@ -0,0 +1,63 @@ +"""Sync LDAP Users and groups into authentik""" +from typing import Any, Optional + +import ldap3 +import ldap3.core.exceptions + +from authentik.core.models import Group, User +from authentik.sources.ldap.auth import LDAP_DISTINGUISHED_NAME +from authentik.sources.ldap.models import LDAPSource +from authentik.sources.ldap.sync.base import LDAP_UNIQUENESS, BaseLDAPSynchronizer + + +class MembershipLDAPSynchronizer(BaseLDAPSynchronizer): + """Sync LDAP Users and groups into authentik""" + + group_cache: dict[str, Group] + + def __init__(self, source: LDAPSource): + super().__init__(source) + self.group_cache: dict[str, Group] = {} + + def sync(self): + """Iterate over all Users and assign Groups using memberOf Field""" + groups = self._source.connection.extend.standard.paged_search( + search_base=self.base_dn_groups, + search_filter=self._source.group_object_filter, + search_scope=ldap3.SUBTREE, + attributes=[ + self._source.group_membership_field, + self._source.object_uniqueness_field, + ], + ) + for group in groups: + members = group.get("attributes", {}).get( + self._source.group_membership_field, [] + ) + users = User.objects.filter( + **{f"attributes__{LDAP_DISTINGUISHED_NAME}__in": members} + ) + + ak_group = self.get_group(group) + if not ak_group: + continue + ak_group.users.set(users) + ak_group.save() + self._logger.debug("Successfully updated group membership") + + def get_group(self, group_dict: dict[str, Any]) -> Optional[Group]: + """Check if we fetched the group already, and if not cache it for later""" + group_uniq = group_dict.get("attributes", {}).get(LDAP_UNIQUENESS, "") + group_dn = group_dict.get("attributes", {}).get(LDAP_DISTINGUISHED_NAME, "") + if group_uniq not in self.group_cache: + groups = Group.objects.filter( + **{f"attributes__{LDAP_UNIQUENESS}": group_uniq} + ) + if not groups.exists(): + self._logger.warning( + "Group does not exist in our DB yet, run sync_groups first.", + group=group_dn, + ) + return None + self.group_cache[group_uniq] = groups.first() + return self.group_cache[group_uniq] diff --git a/authentik/sources/ldap/sync/users.py b/authentik/sources/ldap/sync/users.py new file mode 100644 index 000000000..230e044e4 --- /dev/null +++ b/authentik/sources/ldap/sync/users.py @@ -0,0 +1,97 @@ +"""Sync LDAP Users into authentik""" +from typing import Any + +import ldap3 +import ldap3.core.exceptions +from django.db.utils import IntegrityError + +from authentik.core.exceptions import PropertyMappingExpressionException +from authentik.core.models import User +from authentik.sources.ldap.auth import LDAP_DISTINGUISHED_NAME +from authentik.sources.ldap.models import LDAPPropertyMapping +from authentik.sources.ldap.sync.base import LDAP_UNIQUENESS, BaseLDAPSynchronizer + + +class UserLDAPSynchronizer(BaseLDAPSynchronizer): + """Sync LDAP Users into authentik""" + + def sync(self) -> int: + """Iterate over all LDAP Users and create authentik_core.User instances""" + if not self._source.sync_users: + self._logger.warning("User syncing is disabled for this Source") + return -1 + users = self._source.connection.extend.standard.paged_search( + search_base=self.base_dn_users, + search_filter=self._source.user_object_filter, + search_scope=ldap3.SUBTREE, + attributes=[ldap3.ALL_ATTRIBUTES, ldap3.ALL_OPERATIONAL_ATTRIBUTES], + ) + user_count = 0 + for user in users: + attributes = user.get("attributes", {}) + if self._source.object_uniqueness_field not in attributes: + self._logger.warning( + "Cannot find uniqueness Field in attributes", + attributes=attributes.keys(), + dn=attributes.get(LDAP_DISTINGUISHED_NAME, ""), + ) + continue + uniq = attributes[self._source.object_uniqueness_field] + try: + defaults = self._build_object_properties(attributes) + user, created = User.objects.update_or_create( + **{ + f"attributes__{LDAP_UNIQUENESS}": uniq, + "defaults": defaults, + } + ) + except IntegrityError as exc: + self._logger.warning("Failed to create user", exc=exc) + self._logger.warning( + ( + "To merge new User with existing user, set the User's " + f"Attribute '{LDAP_UNIQUENESS}' to '{uniq}'" + ) + ) + else: + if created: + user.set_unusable_password() + user.save() + self._logger.debug( + "Synced User", user=attributes.get("name", ""), created=created + ) + user_count += 1 + return user_count + + def _build_object_properties( + self, attributes: dict[str, Any] + ) -> dict[str, dict[Any, Any]]: + properties = {"attributes": {}} + for mapping in self._source.property_mappings.all().select_subclasses(): + if not isinstance(mapping, LDAPPropertyMapping): + continue + mapping: LDAPPropertyMapping + try: + value = mapping.evaluate(user=None, request=None, ldap=attributes) + if value is None: + continue + object_field = mapping.object_field + if object_field.startswith("attributes."): + properties["attributes"][ + object_field.replace("attributes.", "") + ] = value + else: + properties[object_field] = value + except PropertyMappingExpressionException as exc: + self._logger.warning( + "Mapping failed to evaluate", exc=exc, mapping=mapping + ) + continue + if self._source.object_uniqueness_field in attributes: + properties["attributes"][LDAP_UNIQUENESS] = attributes.get( + self._source.object_uniqueness_field + ) + properties["attributes"][LDAP_DISTINGUISHED_NAME] = attributes.get( + "distinguishedName", attributes.get("dn") + ) + return properties diff --git a/authentik/sources/ldap/tasks.py b/authentik/sources/ldap/tasks.py index 29ac1696f..3d788e171 100644 --- a/authentik/sources/ldap/tasks.py +++ b/authentik/sources/ldap/tasks.py @@ -8,7 +8,9 @@ from ldap3.core.exceptions import LDAPException from authentik.events.monitored_tasks import MonitoredTask, TaskResult, TaskResultStatus from authentik.root.celery import CELERY_APP from authentik.sources.ldap.models import LDAPSource -from authentik.sources.ldap.sync import LDAPSynchronizer +from authentik.sources.ldap.sync.groups import GroupLDAPSynchronizer +from authentik.sources.ldap.sync.membership import MembershipLDAPSynchronizer +from authentik.sources.ldap.sync.users import UserLDAPSynchronizer @CELERY_APP.task() @@ -29,16 +31,21 @@ def ldap_sync(self: MonitoredTask, source_pk: int): return self.set_uid(slugify(source.name)) try: - syncer = LDAPSynchronizer(source) - user_count = syncer.sync_users() - group_count = syncer.sync_groups() - syncer.sync_membership() + messages = [] + for sync_class in [ + UserLDAPSynchronizer, + GroupLDAPSynchronizer, + MembershipLDAPSynchronizer, + ]: + sync_inst = sync_class(source) + count = sync_inst.sync() + messages.append(f"Synced {count} objects from {sync_class.__name__}") cache_key = source.state_cache_prefix("last_sync") cache.set(cache_key, time(), timeout=60 * 60) self.set_status( TaskResult( TaskResultStatus.SUCCESSFUL, - [f"Synced {user_count} users", f"Synced {group_count} groups"], + messages, ) ) except LDAPException as exc: diff --git a/authentik/sources/ldap/tests/test_auth.py b/authentik/sources/ldap/tests/test_auth.py index 6fbc69946..9dd89dc80 100644 --- a/authentik/sources/ldap/tests/test_auth.py +++ b/authentik/sources/ldap/tests/test_auth.py @@ -8,11 +8,11 @@ from authentik.managed.manager import ObjectManager from authentik.providers.oauth2.generators import generate_client_secret from authentik.sources.ldap.auth import LDAPBackend from authentik.sources.ldap.models import LDAPPropertyMapping, LDAPSource -from authentik.sources.ldap.sync import LDAPSynchronizer -from authentik.sources.ldap.tests.utils import _build_mock_connection +from authentik.sources.ldap.sync.users import UserLDAPSynchronizer +from authentik.sources.ldap.tests.utils import mock_ad_connection LDAP_PASSWORD = generate_client_secret() -LDAP_CONNECTION_PATCH = PropertyMock(return_value=_build_mock_connection(LDAP_PASSWORD)) +LDAP_CONNECTION_PATCH = PropertyMock(return_value=mock_ad_connection(LDAP_PASSWORD)) class LDAPSyncTests(TestCase): @@ -33,8 +33,8 @@ class LDAPSyncTests(TestCase): @patch("authentik.sources.ldap.models.LDAPSource.connection", LDAP_CONNECTION_PATCH) def test_auth_synced_user(self): """Test Cached auth""" - syncer = LDAPSynchronizer(self.source) - syncer.sync_users() + user_sync = UserLDAPSynchronizer(self.source) + user_sync.sync() user = User.objects.get(username="user0_sn") auth_user_by_bind = Mock(return_value=user) diff --git a/authentik/sources/ldap/tests/test_password.py b/authentik/sources/ldap/tests/test_password.py index 82f497e54..d89adb7e4 100644 --- a/authentik/sources/ldap/tests/test_password.py +++ b/authentik/sources/ldap/tests/test_password.py @@ -7,10 +7,10 @@ from authentik.core.models import User from authentik.providers.oauth2.generators import generate_client_secret from authentik.sources.ldap.models import LDAPPropertyMapping, LDAPSource from authentik.sources.ldap.password import LDAPPasswordChanger -from authentik.sources.ldap.tests.utils import _build_mock_connection +from authentik.sources.ldap.tests.utils import mock_ad_connection LDAP_PASSWORD = generate_client_secret() -LDAP_CONNECTION_PATCH = PropertyMock(return_value=_build_mock_connection(LDAP_PASSWORD)) +LDAP_CONNECTION_PATCH = PropertyMock(return_value=mock_ad_connection(LDAP_PASSWORD)) class LDAPPasswordTests(TestCase): diff --git a/authentik/sources/ldap/tests/test_sync.py b/authentik/sources/ldap/tests/test_sync.py index 8b91839b1..aa6cec8df 100644 --- a/authentik/sources/ldap/tests/test_sync.py +++ b/authentik/sources/ldap/tests/test_sync.py @@ -7,12 +7,14 @@ from authentik.core.models import Group, User from authentik.managed.manager import ObjectManager from authentik.providers.oauth2.generators import generate_client_secret from authentik.sources.ldap.models import LDAPPropertyMapping, LDAPSource -from authentik.sources.ldap.sync import LDAPSynchronizer +from authentik.sources.ldap.sync.groups import GroupLDAPSynchronizer +from authentik.sources.ldap.sync.membership import MembershipLDAPSynchronizer +from authentik.sources.ldap.sync.users import UserLDAPSynchronizer from authentik.sources.ldap.tasks import ldap_sync_all -from authentik.sources.ldap.tests.utils import _build_mock_connection +from authentik.sources.ldap.tests.utils import mock_ad_connection LDAP_PASSWORD = generate_client_secret() -LDAP_CONNECTION_PATCH = PropertyMock(return_value=_build_mock_connection(LDAP_PASSWORD)) +LDAP_CONNECTION_PATCH = PropertyMock(return_value=mock_ad_connection(LDAP_PASSWORD)) class LDAPSyncTests(TestCase): @@ -33,17 +35,18 @@ class LDAPSyncTests(TestCase): @patch("authentik.sources.ldap.models.LDAPSource.connection", LDAP_CONNECTION_PATCH) def test_sync_users(self): """Test user sync""" - syncer = LDAPSynchronizer(self.source) - syncer.sync_users() + user_sync = UserLDAPSynchronizer(self.source) + user_sync.sync() self.assertTrue(User.objects.filter(username="user0_sn").exists()) self.assertFalse(User.objects.filter(username="user1_sn").exists()) @patch("authentik.sources.ldap.models.LDAPSource.connection", LDAP_CONNECTION_PATCH) def test_sync_groups(self): """Test group sync""" - syncer = LDAPSynchronizer(self.source) - syncer.sync_groups() - syncer.sync_membership() + group_sync = GroupLDAPSynchronizer(self.source) + group_sync.sync() + membership_sync = MembershipLDAPSynchronizer(self.source) + membership_sync.sync() group = Group.objects.filter(name="test-group") self.assertTrue(group.exists()) diff --git a/authentik/sources/ldap/tests/utils.py b/authentik/sources/ldap/tests/utils.py index 6eb423ccb..b34a71eb0 100644 --- a/authentik/sources/ldap/tests/utils.py +++ b/authentik/sources/ldap/tests/utils.py @@ -3,8 +3,8 @@ from ldap3 import MOCK_SYNC, OFFLINE_AD_2012_R2, Connection, Server -def _build_mock_connection(password: str) -> Connection: - """Create mock connection""" +def mock_ad_connection(password: str) -> Connection: + """Create mock AD connection""" server = Server("my_fake_server", get_info=OFFLINE_AD_2012_R2) _pass = "foo" # noqa # nosec connection = Connection( @@ -32,6 +32,7 @@ def _build_mock_connection(password: str) -> Connection: "objectSid": "unique-test-group", "objectCategory": "Group", "distinguishedName": "cn=group1,ou=groups,DC=AD2012,DC=LAB", + "member": ["cn=user0,ou=users,DC=AD2012,DC=LAB"], }, ) # Group without SID @@ -52,7 +53,6 @@ def _build_mock_connection(password: str) -> Connection: "revision": 0, "objectSid": "user0", "objectCategory": "Person", - "memberOf": "cn=group1,ou=groups,DC=AD2012,DC=LAB", "distinguishedName": "cn=user0,ou=users,DC=AD2012,DC=LAB", }, ) diff --git a/swagger.yaml b/swagger.yaml index 01f2a69d2..141ca2b4b 100755 --- a/swagger.yaml +++ b/swagger.yaml @@ -9140,9 +9140,9 @@ definitions: description: Consider Objects matching this filter to be Groups. type: string minLength: 1 - user_group_membership_field: - title: User group membership field - description: Field which contains Groups of user. + group_membership_field: + title: Group membership field + description: Field which contains members of a group. type: string minLength: 1 object_uniqueness_field: diff --git a/website/docs/integrations/sources/active-directory/index.md b/website/docs/integrations/sources/active-directory/index.md index 63f246441..6e3b6710e 100644 --- a/website/docs/integrations/sources/active-directory/index.md +++ b/website/docs/integrations/sources/active-directory/index.md @@ -48,7 +48,7 @@ The other settings might need to be adjusted based on the setup of your domain. - Addition Group DN: Additional DN which is _prepended_ to your Base DN for group synchronization. - User object filter: Which objects should be considered users. - Group object filter: Which objects should be considered groups. -- User group membership field: Which user field saves the group membership +- Group membership field: Which user field saves the group membership - Object uniqueness field: A user field which contains a unique Identifier - Sync parent group: If enabled, all synchronized groups will be given this group as a parent.