From 14dc4207479c1de55390c1aa978e50cb32798aa8 Mon Sep 17 00:00:00 2001 From: Jens Langhammer Date: Thu, 4 Feb 2021 20:06:42 +0100 Subject: [PATCH 01/13] 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. From 7d107991a26f0233ad47afe61939ba30e4266922 Mon Sep 17 00:00:00 2001 From: Jens Langhammer Date: Thu, 4 Feb 2021 20:22:28 +0100 Subject: [PATCH 02/13] sources/ldap: fix count for membership, fix wrong attribute being searched --- authentik/sources/ldap/sync/base.py | 2 +- authentik/sources/ldap/sync/membership.py | 9 +++++++-- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/authentik/sources/ldap/sync/base.py b/authentik/sources/ldap/sync/base.py index 501d5f956..d9405b0ad 100644 --- a/authentik/sources/ldap/sync/base.py +++ b/authentik/sources/ldap/sync/base.py @@ -30,6 +30,6 @@ class BaseLDAPSynchronizer: return f"{self._source.additional_group_dn},{self._source.base_dn}" return self._source.base_dn - def sync(self): + def sync(self) -> int: """Sync function, implemented in subclass""" raise NotImplementedError() diff --git a/authentik/sources/ldap/sync/membership.py b/authentik/sources/ldap/sync/membership.py index 7fd7e9a1a..72f9c8e12 100644 --- a/authentik/sources/ldap/sync/membership.py +++ b/authentik/sources/ldap/sync/membership.py @@ -19,7 +19,7 @@ class MembershipLDAPSynchronizer(BaseLDAPSynchronizer): super().__init__(source) self.group_cache: dict[str, Group] = {} - def sync(self): + def sync(self) -> int: """Iterate over all Users and assign Groups using memberOf Field""" groups = self._source.connection.extend.standard.paged_search( search_base=self.base_dn_groups, @@ -28,8 +28,10 @@ class MembershipLDAPSynchronizer(BaseLDAPSynchronizer): attributes=[ self._source.group_membership_field, self._source.object_uniqueness_field, + LDAP_DISTINGUISHED_NAME, ], ) + membership_count = 0 for group in groups: members = group.get("attributes", {}).get( self._source.group_membership_field, [] @@ -41,13 +43,16 @@ class MembershipLDAPSynchronizer(BaseLDAPSynchronizer): ak_group = self.get_group(group) if not ak_group: continue + membership_count += 1 + membership_count += users.count() ak_group.users.set(users) ak_group.save() self._logger.debug("Successfully updated group membership") + return membership_count 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_uniq = group_dict.get("attributes", {}).get(self._source.object_uniqueness_field, "") group_dn = group_dict.get("attributes", {}).get(LDAP_DISTINGUISHED_NAME, "") if group_uniq not in self.group_cache: groups = Group.objects.filter( From 005b4d8dda26c98957a63b03d88dd68480e2a006 Mon Sep 17 00:00:00 2001 From: Jens Langhammer Date: Thu, 4 Feb 2021 20:36:05 +0100 Subject: [PATCH 03/13] sources/ldap: fix linting issues --- authentik/sources/ldap/sync/membership.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/authentik/sources/ldap/sync/membership.py b/authentik/sources/ldap/sync/membership.py index 72f9c8e12..3444f3e63 100644 --- a/authentik/sources/ldap/sync/membership.py +++ b/authentik/sources/ldap/sync/membership.py @@ -52,7 +52,9 @@ class MembershipLDAPSynchronizer(BaseLDAPSynchronizer): 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(self._source.object_uniqueness_field, "") + group_uniq = group_dict.get("attributes", {}).get( + self._source.object_uniqueness_field, "" + ) group_dn = group_dict.get("attributes", {}).get(LDAP_DISTINGUISHED_NAME, "") if group_uniq not in self.group_cache: groups = Group.objects.filter( From e639d8ab56c3ae8c4998a0221a676e76cb7a95bd Mon Sep 17 00:00:00 2001 From: Jens Langhammer Date: Thu, 4 Feb 2021 21:18:49 +0100 Subject: [PATCH 04/13] sources/ldap: add case when group does not have uniqueness attribute --- authentik/sources/ldap/sync/membership.py | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/authentik/sources/ldap/sync/membership.py b/authentik/sources/ldap/sync/membership.py index 3444f3e63..6fe6af870 100644 --- a/authentik/sources/ldap/sync/membership.py +++ b/authentik/sources/ldap/sync/membership.py @@ -52,10 +52,19 @@ class MembershipLDAPSynchronizer(BaseLDAPSynchronizer): 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_dn = group_dict.get("attributes", {}).get(LDAP_DISTINGUISHED_NAME, []) group_uniq = group_dict.get("attributes", {}).get( - self._source.object_uniqueness_field, "" + self._source.object_uniqueness_field, [] ) - group_dn = group_dict.get("attributes", {}).get(LDAP_DISTINGUISHED_NAME, "") + # group_uniq might be a single string or an array with (hopefully) a single string + if isinstance(group_uniq, list): + if len(group_uniq) < 1: + self._logger.warning( + "Group does not have a uniqueness attribute.", + group=group_dn, + ) + return None + group_uniq = group_uniq[0] if group_uniq not in self.group_cache: groups = Group.objects.filter( **{f"attributes__{LDAP_UNIQUENESS}": group_uniq} From 51cbb7cc8e8b7b5911543462d8c47647b9075143 Mon Sep 17 00:00:00 2001 From: Jens Langhammer Date: Thu, 4 Feb 2021 23:15:35 +0100 Subject: [PATCH 05/13] ci: fix warning when setting branchName in PR --- azure-pipelines.yml | 4 ++-- outpost/azure-pipelines.yml | 6 +++--- web/azure-pipelines.yml | 4 ++-- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/azure-pipelines.yml b/azure-pipelines.yml index 9c6b36c2a..21c38cf9c 100644 --- a/azure-pipelines.yml +++ b/azure-pipelines.yml @@ -374,8 +374,8 @@ stages: targetType: 'inline' script: | set -x - branchName=$(echo "$(System.PullRequest.SourceBranch)" | sed "s/\//-/g")' - echo '##vso[task.setvariable variable=branchName]$branchName + branchName=$(echo "$(System.PullRequest.SourceBranch)" | sed "s/\//-/g") + echo "##vso[task.setvariable variable=branchName]$branchName" - task: Docker@2 inputs: containerRegistry: 'dockerhub' diff --git a/outpost/azure-pipelines.yml b/outpost/azure-pipelines.yml index 4dab4aecb..410df0d9a 100644 --- a/outpost/azure-pipelines.yml +++ b/outpost/azure-pipelines.yml @@ -94,12 +94,12 @@ stages: targetType: 'inline' script: | set -x - branchName=$(echo "$(System.PullRequest.SourceBranch)" | sed "s/\//-/g")' - echo '##vso[task.setvariable variable=branchName]$branchName + branchName=$(echo "$(System.PullRequest.SourceBranch)" | sed "s/\//-/g") + echo "##vso[task.setvariable variable=branchName]$branchName" - task: Docker@2 inputs: containerRegistry: 'dockerhub' - repository: 'beryju/authentik-outpost' + repository: 'beryju/authentik-proxy' command: 'buildAndPush' Dockerfile: 'outpost/proxy.Dockerfile' buildContext: 'outpost/' diff --git a/web/azure-pipelines.yml b/web/azure-pipelines.yml index 0060e0e2e..32aaec773 100644 --- a/web/azure-pipelines.yml +++ b/web/azure-pipelines.yml @@ -74,8 +74,8 @@ stages: targetType: 'inline' script: | set -x - branchName=$(echo "$(System.PullRequest.SourceBranch)" | sed "s/\//-/g")' - echo '##vso[task.setvariable variable=branchName]$branchName + branchName=$(echo "$(System.PullRequest.SourceBranch)" | sed "s/\//-/g") + echo "##vso[task.setvariable variable=branchName]$branchName" - task: Docker@2 inputs: containerRegistry: 'dockerhub' From b0e3b8b39d412918779394e049e32da586d644f4 Mon Sep 17 00:00:00 2001 From: Jens Langhammer Date: Fri, 5 Feb 2021 11:43:13 +0100 Subject: [PATCH 06/13] sources/ldap: use entryDN attribute from ldap3 as opposed to implicit DN attribute --- authentik/sources/ldap/sync/users.py | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/authentik/sources/ldap/sync/users.py b/authentik/sources/ldap/sync/users.py index 230e044e4..b0f25d34e 100644 --- a/authentik/sources/ldap/sync/users.py +++ b/authentik/sources/ldap/sync/users.py @@ -29,16 +29,17 @@ class UserLDAPSynchronizer(BaseLDAPSynchronizer): user_count = 0 for user in users: attributes = user.get("attributes", {}) + user_dn = user.get("entryDN", "") 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, ""), + dn=user_dn, ) continue uniq = attributes[self._source.object_uniqueness_field] try: - defaults = self._build_object_properties(attributes) + defaults = self._build_object_properties(user_dn, **attributes) user, created = User.objects.update_or_create( **{ f"attributes__{LDAP_UNIQUENESS}": uniq, @@ -64,7 +65,7 @@ class UserLDAPSynchronizer(BaseLDAPSynchronizer): return user_count def _build_object_properties( - self, attributes: dict[str, Any] + self, user_dn: str, **kwargs ) -> dict[str, dict[Any, Any]]: properties = {"attributes": {}} for mapping in self._source.property_mappings.all().select_subclasses(): @@ -72,7 +73,9 @@ class UserLDAPSynchronizer(BaseLDAPSynchronizer): continue mapping: LDAPPropertyMapping try: - value = mapping.evaluate(user=None, request=None, ldap=attributes) + value = mapping.evaluate( + user=None, request=None, ldap=kwargs, dn=user_dn + ) if value is None: continue object_field = mapping.object_field @@ -87,11 +90,9 @@ class UserLDAPSynchronizer(BaseLDAPSynchronizer): "Mapping failed to evaluate", exc=exc, mapping=mapping ) continue - if self._source.object_uniqueness_field in attributes: - properties["attributes"][LDAP_UNIQUENESS] = attributes.get( + if self._source.object_uniqueness_field in kwargs: + properties["attributes"][LDAP_UNIQUENESS] = kwargs.get( self._source.object_uniqueness_field ) - properties["attributes"][LDAP_DISTINGUISHED_NAME] = attributes.get( - "distinguishedName", attributes.get("dn") - ) + properties["attributes"][LDAP_DISTINGUISHED_NAME] = user_dn return properties From 397dfc29f1df2bd389bede3cfbcb9a0cdf7dcbc5 Mon Sep 17 00:00:00 2001 From: Jens Langhammer Date: Fri, 5 Feb 2021 11:43:39 +0100 Subject: [PATCH 07/13] sources/ldap: change default object filters to use objectClass= instead of objectCategory --- .../migrations/0010_auto_20210205_1027.py | 29 +++++++++++++++++++ authentik/sources/ldap/models.py | 4 +-- 2 files changed, 31 insertions(+), 2 deletions(-) create mode 100644 authentik/sources/ldap/migrations/0010_auto_20210205_1027.py diff --git a/authentik/sources/ldap/migrations/0010_auto_20210205_1027.py b/authentik/sources/ldap/migrations/0010_auto_20210205_1027.py new file mode 100644 index 000000000..4ef47a541 --- /dev/null +++ b/authentik/sources/ldap/migrations/0010_auto_20210205_1027.py @@ -0,0 +1,29 @@ +# Generated by Django 3.1.6 on 2021-02-05 10:27 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("authentik_sources_ldap", "0009_auto_20210204_1834"), + ] + + operations = [ + migrations.AlterField( + model_name="ldapsource", + name="group_object_filter", + field=models.TextField( + default="(objectClass=group)", + help_text="Consider Objects matching this filter to be Groups.", + ), + ), + migrations.AlterField( + model_name="ldapsource", + name="user_object_filter", + field=models.TextField( + default="(objectClass=person)", + help_text="Consider Objects matching this filter to be Users.", + ), + ), + ] diff --git a/authentik/sources/ldap/models.py b/authentik/sources/ldap/models.py index 6dfa45005..fd53b9fcb 100644 --- a/authentik/sources/ldap/models.py +++ b/authentik/sources/ldap/models.py @@ -38,14 +38,14 @@ class LDAPSource(Source): ) user_object_filter = models.TextField( - default="(objectCategory=Person)", + default="(objectClass=person)", help_text=_("Consider Objects matching this filter to be Users."), ) group_membership_field = models.TextField( default="member", help_text=_("Field which contains members of a group.") ) group_object_filter = models.TextField( - default="(objectCategory=Group)", + default="(objectClass=group)", help_text=_("Consider Objects matching this filter to be Groups."), ) object_uniqueness_field = models.TextField( From fadf746234981b3aecca5c49aab919fb39de58b4 Mon Sep 17 00:00:00 2001 From: Jens Langhammer Date: Fri, 5 Feb 2021 13:18:44 +0100 Subject: [PATCH 08/13] managed: allow for matching on multiple interfaces --- authentik/managed/manager.py | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/authentik/managed/manager.py b/authentik/managed/manager.py index 259259979..654b3772d 100644 --- a/authentik/managed/manager.py +++ b/authentik/managed/manager.py @@ -12,12 +12,12 @@ class EnsureOp: """Ensure operation, executed as part of an ObjectManager run""" _obj: Type[ManagedModel] - _match_field: str + _match_fields: list[str] _kwargs: dict - def __init__(self, obj: Type[ManagedModel], match_field: str, **kwargs) -> None: + def __init__(self, obj: Type[ManagedModel], *match_fields: str, **kwargs) -> None: self._obj = obj - self._match_field = match_field + self._match_fields = match_fields self._kwargs = kwargs def run(self): @@ -29,14 +29,15 @@ class EnsureExists(EnsureOp): """Ensure object exists, with kwargs as given values""" def run(self): - matcher_value = self._kwargs.get(self._match_field, None) + update_kwargs = { + "managed": True, + "defaults": self._kwargs, + } + for field in self._match_fields: + update_kwargs[field] = self._kwargs.get(field, None) self._kwargs.setdefault("managed", True) self._obj.objects.update_or_create( - **{ - self._match_field: matcher_value, - "managed": True, - "defaults": self._kwargs, - } + **update_kwargs ) From 9c1ade59e9c7364df28a252a24e1d2764484deca Mon Sep 17 00:00:00 2001 From: Jens Langhammer Date: Fri, 5 Feb 2021 13:19:24 +0100 Subject: [PATCH 09/13] sources/ldap: add more flatten to user sync, start adding tests for OpenLDAP --- authentik/managed/manager.py | 10 +-- authentik/sources/ldap/managed.py | 13 ++- authentik/sources/ldap/sync/base.py | 10 +++ authentik/sources/ldap/sync/users.py | 20 +++-- .../ldap/tests/{utils.py => mock_ad.py} | 46 +++++----- authentik/sources/ldap/tests/mock_slapd.py | 81 ++++++++++++++++++ authentik/sources/ldap/tests/test_auth.py | 84 ++++++++++++++----- authentik/sources/ldap/tests/test_password.py | 6 +- authentik/sources/ldap/tests/test_sync.py | 4 +- 9 files changed, 212 insertions(+), 62 deletions(-) rename authentik/sources/ldap/tests/{utils.py => mock_ad.py} (60%) create mode 100644 authentik/sources/ldap/tests/mock_slapd.py diff --git a/authentik/managed/manager.py b/authentik/managed/manager.py index 654b3772d..e03927af5 100644 --- a/authentik/managed/manager.py +++ b/authentik/managed/manager.py @@ -12,7 +12,7 @@ class EnsureOp: """Ensure operation, executed as part of an ObjectManager run""" _obj: Type[ManagedModel] - _match_fields: list[str] + _match_fields: tuple[str, ...] _kwargs: dict def __init__(self, obj: Type[ManagedModel], *match_fields: str, **kwargs) -> None: @@ -34,11 +34,11 @@ class EnsureExists(EnsureOp): "defaults": self._kwargs, } for field in self._match_fields: - update_kwargs[field] = self._kwargs.get(field, None) + value = self._kwargs.get(field, None) + if value: + update_kwargs[field] = value self._kwargs.setdefault("managed", True) - self._obj.objects.update_or_create( - **update_kwargs - ) + self._obj.objects.update_or_create(**update_kwargs) class ObjectManager: diff --git a/authentik/sources/ldap/managed.py b/authentik/sources/ldap/managed.py index 37ad59dc9..f53c92df4 100644 --- a/authentik/sources/ldap/managed.py +++ b/authentik/sources/ldap/managed.py @@ -11,7 +11,7 @@ class LDAPProviderManager(ObjectManager): EnsureExists( LDAPPropertyMapping, "object_field", - name="authentik default LDAP Mapping: Name", + name="authentik default LDAP Mapping: name", object_field="name", expression="return ldap.get('name')", ), @@ -22,9 +22,11 @@ class LDAPProviderManager(ObjectManager): object_field="email", expression="return ldap.get('mail')", ), + # Active Directory-specific mappings EnsureExists( LDAPPropertyMapping, "object_field", + "expression", name="authentik default Active Directory Mapping: sAMAccountName", object_field="username", expression="return ldap.get('sAMAccountName')", @@ -36,4 +38,13 @@ class LDAPProviderManager(ObjectManager): object_field="attributes.upn", expression="return ldap.get('userPrincipalName')", ), + # OpenLDAP specific mappings + EnsureExists( + LDAPPropertyMapping, + "object_field", + "expression", + name="authentik default OpenLDAP Mapping: uid", + object_field="username", + expression="return ldap.get('uid')", + ), ] diff --git a/authentik/sources/ldap/sync/base.py b/authentik/sources/ldap/sync/base.py index d9405b0ad..38ce37b62 100644 --- a/authentik/sources/ldap/sync/base.py +++ b/authentik/sources/ldap/sync/base.py @@ -1,4 +1,6 @@ """Sync LDAP Users and groups into authentik""" +from typing import Any + from structlog.stdlib import BoundLogger, get_logger from authentik.sources.ldap.models import LDAPSource @@ -33,3 +35,11 @@ class BaseLDAPSynchronizer: def sync(self) -> int: """Sync function, implemented in subclass""" raise NotImplementedError() + + def _flatten(self, value: Any) -> Any: + """Flatten `value` if its a list""" + if isinstance(value, list): + if len(value) < 1: + return None + return value[0] + return value diff --git a/authentik/sources/ldap/sync/users.py b/authentik/sources/ldap/sync/users.py index b0f25d34e..fed63b9a1 100644 --- a/authentik/sources/ldap/sync/users.py +++ b/authentik/sources/ldap/sync/users.py @@ -28,8 +28,9 @@ class UserLDAPSynchronizer(BaseLDAPSynchronizer): ) user_count = 0 for user in users: + self._logger.debug(user) attributes = user.get("attributes", {}) - user_dn = user.get("entryDN", "") + user_dn = self._flatten(user.get("entryDN", "")) if self._source.object_uniqueness_field not in attributes: self._logger.warning( "Cannot find uniqueness Field in attributes", @@ -37,9 +38,12 @@ class UserLDAPSynchronizer(BaseLDAPSynchronizer): dn=user_dn, ) continue - uniq = attributes[self._source.object_uniqueness_field] + uniq = self._flatten(attributes[self._source.object_uniqueness_field]) try: defaults = self._build_object_properties(user_dn, **attributes) + self._logger.debug("Creating user with attributes", **defaults) + if "username" not in defaults: + raise IntegrityError("Username was not set by propertymappings") user, created = User.objects.update_or_create( **{ f"attributes__{LDAP_UNIQUENESS}": uniq, @@ -58,9 +62,7 @@ class UserLDAPSynchronizer(BaseLDAPSynchronizer): if created: user.set_unusable_password() user.save() - self._logger.debug( - "Synced User", user=attributes.get("name", ""), created=created - ) + self._logger.debug("Synced User", user=user.username, created=created) user_count += 1 return user_count @@ -80,19 +82,21 @@ class UserLDAPSynchronizer(BaseLDAPSynchronizer): continue object_field = mapping.object_field if object_field.startswith("attributes."): + # Because returning a list might desired, we can't + # rely on self._flatten here. Instead, just save the result as-is properties["attributes"][ object_field.replace("attributes.", "") ] = value else: - properties[object_field] = value + properties[object_field] = self._flatten(value) except PropertyMappingExpressionException as exc: self._logger.warning( "Mapping failed to evaluate", exc=exc, mapping=mapping ) continue if self._source.object_uniqueness_field in kwargs: - properties["attributes"][LDAP_UNIQUENESS] = kwargs.get( - self._source.object_uniqueness_field + properties["attributes"][LDAP_UNIQUENESS] = self._flatten( + kwargs.get(self._source.object_uniqueness_field) ) properties["attributes"][LDAP_DISTINGUISHED_NAME] = user_dn return properties diff --git a/authentik/sources/ldap/tests/utils.py b/authentik/sources/ldap/tests/mock_ad.py similarity index 60% rename from authentik/sources/ldap/tests/utils.py rename to authentik/sources/ldap/tests/mock_ad.py index b34a71eb0..c94519f0f 100644 --- a/authentik/sources/ldap/tests/utils.py +++ b/authentik/sources/ldap/tests/mock_ad.py @@ -9,88 +9,88 @@ def mock_ad_connection(password: str) -> Connection: _pass = "foo" # noqa # nosec connection = Connection( server, - user="cn=my_user,DC=AD2012,DC=LAB", + user="cn=my_user,dc=goauthentik,dc=io", password=_pass, client_strategy=MOCK_SYNC, ) # Entry for password checking connection.strategy.add_entry( - "cn=user,ou=users,DC=AD2012,DC=LAB", + "cn=user,ou=users,dc=goauthentik,dc=io", { "name": "test-user", "objectSid": "unique-test-group", - "objectCategory": "Person", + "objectClass": "person", "displayName": "Erin M. Hagens", "sAMAccountName": "sAMAccountName", - "distinguishedName": "cn=user,ou=users,DC=AD2012,DC=LAB", + "distinguishedName": "cn=user,ou=users,dc=goauthentik,dc=io", }, ) connection.strategy.add_entry( - "cn=group1,ou=groups,DC=AD2012,DC=LAB", + "cn=group1,ou=groups,dc=goauthentik,dc=io", { "name": "test-group", "objectSid": "unique-test-group", - "objectCategory": "Group", - "distinguishedName": "cn=group1,ou=groups,DC=AD2012,DC=LAB", - "member": ["cn=user0,ou=users,DC=AD2012,DC=LAB"], + "objectClass": "group", + "distinguishedName": "cn=group1,ou=groups,dc=goauthentik,dc=io", + "member": ["cn=user0,ou=users,dc=goauthentik,dc=io"], }, ) # Group without SID connection.strategy.add_entry( - "cn=group2,ou=groups,DC=AD2012,DC=LAB", + "cn=group2,ou=groups,dc=goauthentik,dc=io", { "name": "test-group", - "objectCategory": "Group", - "distinguishedName": "cn=group2,ou=groups,DC=AD2012,DC=LAB", + "objectClass": "group", + "distinguishedName": "cn=group2,ou=groups,dc=goauthentik,dc=io", }, ) connection.strategy.add_entry( - "cn=user0,ou=users,DC=AD2012,DC=LAB", + "cn=user0,ou=users,dc=goauthentik,dc=io", { "userPassword": password, "sAMAccountName": "user0_sn", "name": "user0_sn", "revision": 0, "objectSid": "user0", - "objectCategory": "Person", - "distinguishedName": "cn=user0,ou=users,DC=AD2012,DC=LAB", + "objectClass": "person", + "distinguishedName": "cn=user0,ou=users,dc=goauthentik,dc=io", }, ) # User without SID connection.strategy.add_entry( - "cn=user1,ou=users,DC=AD2012,DC=LAB", + "cn=user1,ou=users,dc=goauthentik,dc=io", { "userPassword": "test1111", "sAMAccountName": "user2_sn", "name": "user1_sn", "revision": 0, - "objectCategory": "Person", - "distinguishedName": "cn=user1,ou=users,DC=AD2012,DC=LAB", + "objectClass": "person", + "distinguishedName": "cn=user1,ou=users,dc=goauthentik,dc=io", }, ) # Duplicate users connection.strategy.add_entry( - "cn=user2,ou=users,DC=AD2012,DC=LAB", + "cn=user2,ou=users,dc=goauthentik,dc=io", { "userPassword": "test2222", "sAMAccountName": "user2_sn", "name": "user2_sn", "revision": 0, "objectSid": "unique-test2222", - "objectCategory": "Person", - "distinguishedName": "cn=user2,ou=users,DC=AD2012,DC=LAB", + "objectClass": "person", + "distinguishedName": "cn=user2,ou=users,dc=goauthentik,dc=io", }, ) connection.strategy.add_entry( - "cn=user3,ou=users,DC=AD2012,DC=LAB", + "cn=user3,ou=users,dc=goauthentik,dc=io", { "userPassword": "test2222", "sAMAccountName": "user2_sn", "name": "user2_sn", "revision": 0, "objectSid": "unique-test2222", - "objectCategory": "Person", - "distinguishedName": "cn=user3,ou=users,DC=AD2012,DC=LAB", + "objectClass": "person", + "distinguishedName": "cn=user3,ou=users,dc=goauthentik,dc=io", }, ) connection.bind() diff --git a/authentik/sources/ldap/tests/mock_slapd.py b/authentik/sources/ldap/tests/mock_slapd.py new file mode 100644 index 000000000..e4e4af18a --- /dev/null +++ b/authentik/sources/ldap/tests/mock_slapd.py @@ -0,0 +1,81 @@ +"""ldap testing utils""" + +from ldap3 import MOCK_SYNC, OFFLINE_SLAPD_2_4, Connection, Server + + +def mock_slapd_connection(password: str) -> Connection: + """Create mock AD connection""" + server = Server("my_fake_server", get_info=OFFLINE_SLAPD_2_4) + _pass = "foo" # noqa # nosec + connection = Connection( + server, + user="cn=my_user,dc=goauthentik,dc=io", + password=_pass, + client_strategy=MOCK_SYNC, + ) + # Entry for password checking + connection.strategy.add_entry( + "cn=user,ou=users,dc=goauthentik,dc=io", + { + "name": "test-user", + "uid": "unique-test-group", + "objectClass": "person", + "displayName": "Erin M. Hagens", + }, + ) + connection.strategy.add_entry( + "cn=group1,ou=groups,dc=goauthentik,dc=io", + { + "name": "test-group", + "uid": "unique-test-group", + "objectClass": "group", + "member": ["cn=user0,ou=users,dc=goauthentik,dc=io"], + }, + ) + # Group without SID + connection.strategy.add_entry( + "cn=group2,ou=groups,dc=goauthentik,dc=io", + { + "name": "test-group", + "objectClass": "group", + }, + ) + connection.strategy.add_entry( + "cn=user0,ou=users,dc=goauthentik,dc=io", + { + "userPassword": password, + "name": "user0_sn", + "uid": "user0_sn", + "objectClass": "person", + }, + ) + # User without SID + connection.strategy.add_entry( + "cn=user1,ou=users,dc=goauthentik,dc=io", + { + "userPassword": "test1111", + "name": "user1_sn", + "objectClass": "person", + }, + ) + # Duplicate users + connection.strategy.add_entry( + "cn=user2,ou=users,dc=goauthentik,dc=io", + { + "userPassword": "test2222", + "name": "user2_sn", + "uid": "unique-test2222", + "objectClass": "person", + }, + ) + connection.strategy.add_entry( + "cn=user3,ou=users,dc=goauthentik,dc=io", + { + "userPassword": "test2222", + "name": "user2_sn", + "uid": "unique-test2222", + "objectClass": "person", + }, + ) + connection.bind() + return connection diff --git a/authentik/sources/ldap/tests/test_auth.py b/authentik/sources/ldap/tests/test_auth.py index 9dd89dc80..5492f281a 100644 --- a/authentik/sources/ldap/tests/test_auth.py +++ b/authentik/sources/ldap/tests/test_auth.py @@ -1,6 +1,7 @@ """LDAP Source tests""" from unittest.mock import Mock, PropertyMock, patch +from django.db.models import Q from django.test import TestCase from authentik.core.models import User @@ -9,10 +10,10 @@ 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.users import UserLDAPSynchronizer -from authentik.sources.ldap.tests.utils import mock_ad_connection +from authentik.sources.ldap.tests.mock_ad import mock_ad_connection +from authentik.sources.ldap.tests.mock_slapd import mock_slapd_connection LDAP_PASSWORD = generate_client_secret() -LDAP_CONNECTION_PATCH = PropertyMock(return_value=mock_ad_connection(LDAP_PASSWORD)) class LDAPSyncTests(TestCase): @@ -23,27 +24,70 @@ class LDAPSyncTests(TestCase): self.source = LDAPSource.objects.create( name="ldap", slug="ldap", - base_dn="DC=AD2012,DC=LAB", + base_dn="dc=goauthentik,dc=io", additional_user_dn="ou=users", additional_group_dn="ou=groups", ) - self.source.property_mappings.set(LDAPPropertyMapping.objects.all()) - self.source.save() - @patch("authentik.sources.ldap.models.LDAPSource.connection", LDAP_CONNECTION_PATCH) - def test_auth_synced_user(self): + def test_auth_synced_user_ad(self): """Test Cached auth""" - user_sync = UserLDAPSynchronizer(self.source) - user_sync.sync() - - user = User.objects.get(username="user0_sn") - auth_user_by_bind = Mock(return_value=user) - with patch( - "authentik.sources.ldap.auth.LDAPBackend.auth_user_by_bind", - auth_user_by_bind, - ): - backend = LDAPBackend() - self.assertEqual( - backend.authenticate(None, username="user0_sn", password=LDAP_PASSWORD), - user, + self.source.property_mappings.set( + LDAPPropertyMapping.objects.filter( + Q(name__startswith="authentik default LDAP Mapping") + | Q(name__startswith="authentik default Active Directory Mapping") ) + ) + print( + LDAPPropertyMapping.objects.filter( + Q(name__startswith="authentik default LDAP Mapping") + | Q(name__startswith="authentik default Active Directory Mapping") + ) + ) + self.source.save() + connection = PropertyMock(return_value=mock_ad_connection(LDAP_PASSWORD)) + with patch("authentik.sources.ldap.models.LDAPSource.connection", connection): + user_sync = UserLDAPSynchronizer(self.source) + user_sync.sync() + + user = User.objects.get(username="user0_sn") + auth_user_by_bind = Mock(return_value=user) + with patch( + "authentik.sources.ldap.auth.LDAPBackend.auth_user_by_bind", + auth_user_by_bind, + ): + backend = LDAPBackend() + self.assertEqual( + backend.authenticate( + None, username="user0_sn", password=LDAP_PASSWORD + ), + user, + ) + + def test_auth_synced_user_openldap(self): + """Test Cached auth""" + self.source.object_uniqueness_field = "uid" + self.source.property_mappings.set( + LDAPPropertyMapping.objects.filter( + Q(name__startswith="authentik default LDAP Mapping") + | Q(name__startswith="authentik default OpenLDAP Mapping") + ) + ) + self.source.save() + connection = PropertyMock(return_value=mock_slapd_connection(LDAP_PASSWORD)) + with patch("authentik.sources.ldap.models.LDAPSource.connection", connection): + user_sync = UserLDAPSynchronizer(self.source) + user_sync.sync() + + user = User.objects.get(username="user0_sn") + auth_user_by_bind = Mock(return_value=user) + with patch( + "authentik.sources.ldap.auth.LDAPBackend.auth_user_by_bind", + auth_user_by_bind, + ): + backend = LDAPBackend() + self.assertEqual( + backend.authenticate( + None, username="user0_sn", password=LDAP_PASSWORD + ), + user, + ) diff --git a/authentik/sources/ldap/tests/test_password.py b/authentik/sources/ldap/tests/test_password.py index d89adb7e4..91a32abf2 100644 --- a/authentik/sources/ldap/tests/test_password.py +++ b/authentik/sources/ldap/tests/test_password.py @@ -7,7 +7,7 @@ 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 mock_ad_connection +from authentik.sources.ldap.tests.mock_ad import mock_ad_connection LDAP_PASSWORD = generate_client_secret() LDAP_CONNECTION_PATCH = PropertyMock(return_value=mock_ad_connection(LDAP_PASSWORD)) @@ -20,7 +20,7 @@ class LDAPPasswordTests(TestCase): self.source = LDAPSource.objects.create( name="ldap", slug="ldap", - base_dn="DC=AD2012,DC=LAB", + base_dn="dc=goauthentik,dc=io", additional_user_dn="ou=users", additional_group_dn="ou=groups", ) @@ -41,7 +41,7 @@ class LDAPPasswordTests(TestCase): pwc = LDAPPasswordChanger(self.source) user = User.objects.create( username="test", - attributes={"distinguishedName": "cn=user,ou=users,DC=AD2012,DC=LAB"}, + attributes={"distinguishedName": "cn=user,ou=users,dc=goauthentik,dc=io"}, ) self.assertFalse(pwc.ad_password_complexity("test", user)) # 1 category self.assertFalse(pwc.ad_password_complexity("test1", user)) # 2 categories diff --git a/authentik/sources/ldap/tests/test_sync.py b/authentik/sources/ldap/tests/test_sync.py index aa6cec8df..d680b8e45 100644 --- a/authentik/sources/ldap/tests/test_sync.py +++ b/authentik/sources/ldap/tests/test_sync.py @@ -11,7 +11,7 @@ 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 mock_ad_connection +from authentik.sources.ldap.tests.mock_ad import mock_ad_connection LDAP_PASSWORD = generate_client_secret() LDAP_CONNECTION_PATCH = PropertyMock(return_value=mock_ad_connection(LDAP_PASSWORD)) @@ -25,7 +25,7 @@ class LDAPSyncTests(TestCase): self.source = LDAPSource.objects.create( name="ldap", slug="ldap", - base_dn="DC=AD2012,DC=LAB", + base_dn="dc=goauthentik,dc=io", additional_user_dn="ou=users", additional_group_dn="ou=groups", ) From 478d3430eb622b9a9a40228e656b5f17ab62453d Mon Sep 17 00:00:00 2001 From: Jens Langhammer Date: Fri, 5 Feb 2021 14:29:22 +0100 Subject: [PATCH 10/13] sources/ldap: use openldap tests for entire sync --- authentik/sources/ldap/sync/groups.py | 13 +-- authentik/sources/ldap/tests/mock_slapd.py | 4 +- authentik/sources/ldap/tests/test_auth.py | 6 -- authentik/sources/ldap/tests/test_sync.py | 116 +++++++++++++++++---- 4 files changed, 104 insertions(+), 35 deletions(-) diff --git a/authentik/sources/ldap/sync/groups.py b/authentik/sources/ldap/sync/groups.py index 05a8df647..2d71de62f 100644 --- a/authentik/sources/ldap/sync/groups.py +++ b/authentik/sources/ldap/sync/groups.py @@ -24,31 +24,32 @@ class GroupLDAPSynchronizer(BaseLDAPSynchronizer): group_count = 0 for group in groups: attributes = group.get("attributes", {}) + group_dn = self._flatten(group.get("entryDN", "")) 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, ""), + dn=group_dn, ) continue uniq = attributes[self._source.object_uniqueness_field] + # TODO: Use Property Mappings + name = self._flatten(attributes.get("name", "")) _, created = Group.objects.update_or_create( **{ f"attributes__{LDAP_UNIQUENESS}": uniq, "parent": self._source.sync_parent_group, "defaults": { - "name": attributes.get("name", ""), + "name": name, "attributes": { LDAP_UNIQUENESS: uniq, - LDAP_DISTINGUISHED_NAME: attributes.get( - "distinguishedName" - ), + LDAP_DISTINGUISHED_NAME: group_dn, }, }, } ) self._logger.debug( - "Synced group", group=attributes.get("name", ""), created=created + "Synced group", group=name, created=created ) group_count += 1 return group_count diff --git a/authentik/sources/ldap/tests/mock_slapd.py b/authentik/sources/ldap/tests/mock_slapd.py index e4e4af18a..b570f6652 100644 --- a/authentik/sources/ldap/tests/mock_slapd.py +++ b/authentik/sources/ldap/tests/mock_slapd.py @@ -28,7 +28,7 @@ def mock_slapd_connection(password: str) -> Connection: { "name": "test-group", "uid": "unique-test-group", - "objectClass": "group", + "objectClass": "groupOfNames", "member": ["cn=user0,ou=users,dc=goauthentik,dc=io"], }, ) @@ -37,7 +37,7 @@ def mock_slapd_connection(password: str) -> Connection: "cn=group2,ou=groups,dc=goauthentik,dc=io", { "name": "test-group", - "objectClass": "group", + "objectClass": "groupOfNames", }, ) connection.strategy.add_entry( diff --git a/authentik/sources/ldap/tests/test_auth.py b/authentik/sources/ldap/tests/test_auth.py index 5492f281a..92eb7cb52 100644 --- a/authentik/sources/ldap/tests/test_auth.py +++ b/authentik/sources/ldap/tests/test_auth.py @@ -37,12 +37,6 @@ class LDAPSyncTests(TestCase): | Q(name__startswith="authentik default Active Directory Mapping") ) ) - print( - LDAPPropertyMapping.objects.filter( - Q(name__startswith="authentik default LDAP Mapping") - | Q(name__startswith="authentik default Active Directory Mapping") - ) - ) self.source.save() connection = PropertyMock(return_value=mock_ad_connection(LDAP_PASSWORD)) with patch("authentik.sources.ldap.models.LDAPSource.connection", connection): diff --git a/authentik/sources/ldap/tests/test_sync.py b/authentik/sources/ldap/tests/test_sync.py index d680b8e45..4c40ab733 100644 --- a/authentik/sources/ldap/tests/test_sync.py +++ b/authentik/sources/ldap/tests/test_sync.py @@ -1,6 +1,8 @@ """LDAP Source tests""" +from authentik.sources.ldap.tests.mock_slapd import mock_slapd_connection from unittest.mock import PropertyMock, patch +from django.db.models import Q from django.test import TestCase from authentik.core.models import Group, User @@ -14,8 +16,6 @@ from authentik.sources.ldap.tasks import ldap_sync_all from authentik.sources.ldap.tests.mock_ad import mock_ad_connection LDAP_PASSWORD = generate_client_secret() -LDAP_CONNECTION_PATCH = PropertyMock(return_value=mock_ad_connection(LDAP_PASSWORD)) - class LDAPSyncTests(TestCase): """LDAP Sync tests""" @@ -29,28 +29,102 @@ class LDAPSyncTests(TestCase): additional_user_dn="ou=users", additional_group_dn="ou=groups", ) - self.source.property_mappings.set(LDAPPropertyMapping.objects.all()) - self.source.save() - @patch("authentik.sources.ldap.models.LDAPSource.connection", LDAP_CONNECTION_PATCH) - def test_sync_users(self): + def test_sync_users_ad(self): """Test user sync""" - 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()) + self.source.property_mappings.set( + LDAPPropertyMapping.objects.filter( + Q(name__startswith="authentik default LDAP Mapping") + | Q(name__startswith="authentik default Active Directory Mapping") + ) + ) + self.source.save() + connection = PropertyMock(return_value=mock_ad_connection(LDAP_PASSWORD)) + with patch("authentik.sources.ldap.models.LDAPSource.connection", connection): + 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): + def test_sync_users_openldap(self): + """Test user sync""" + self.source.object_uniqueness_field = "uid" + self.source.property_mappings.set( + LDAPPropertyMapping.objects.filter( + Q(name__startswith="authentik default LDAP Mapping") + | Q(name__startswith="authentik default OpenLDAP Mapping") + ) + ) + self.source.save() + connection = PropertyMock(return_value=mock_slapd_connection(LDAP_PASSWORD)) + with patch("authentik.sources.ldap.models.LDAPSource.connection", connection): + 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()) + + def test_sync_groups_ad(self): """Test group sync""" - 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()) + self.source.property_mappings.set( + LDAPPropertyMapping.objects.filter( + Q(name__startswith="authentik default LDAP Mapping") + | Q(name__startswith="authentik default Active Directory Mapping") + ) + ) + self.source.save() + connection = PropertyMock(return_value=mock_ad_connection(LDAP_PASSWORD)) + with patch("authentik.sources.ldap.models.LDAPSource.connection", connection): + 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()) - @patch("authentik.sources.ldap.models.LDAPSource.connection", LDAP_CONNECTION_PATCH) - def test_tasks(self): + def test_sync_groups_openldap(self): + """Test group sync""" + self.source.object_uniqueness_field = "uid" + self.source.group_object_filter = "(objectClass=groupOfNames)" + self.source.property_mappings.set( + LDAPPropertyMapping.objects.filter( + Q(name__startswith="authentik default LDAP Mapping") + | Q(name__startswith="authentik default OpenLDAP Mapping") + ) + ) + self.source.save() + connection = PropertyMock(return_value=mock_slapd_connection(LDAP_PASSWORD)) + with patch("authentik.sources.ldap.models.LDAPSource.connection", connection): + 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()) + + def test_tasks_ad(self): """Test Scheduled tasks""" - ldap_sync_all.delay().get() + self.source.property_mappings.set( + LDAPPropertyMapping.objects.filter( + Q(name__startswith="authentik default LDAP Mapping") + | Q(name__startswith="authentik default Active Directory Mapping") + ) + ) + self.source.save() + connection = PropertyMock(return_value=mock_ad_connection(LDAP_PASSWORD)) + with patch("authentik.sources.ldap.models.LDAPSource.connection", connection): + ldap_sync_all.delay().get() + + def test_tasks_openldap(self): + """Test Scheduled tasks""" + self.source.object_uniqueness_field = "uid" + self.source.group_object_filter = "(objectClass=groupOfNames)" + self.source.property_mappings.set( + LDAPPropertyMapping.objects.filter( + Q(name__startswith="authentik default LDAP Mapping") + | Q(name__startswith="authentik default OpenLDAP Mapping") + ) + ) + self.source.save() + connection = PropertyMock(return_value=mock_slapd_connection(LDAP_PASSWORD)) + with patch("authentik.sources.ldap.models.LDAPSource.connection", connection): + ldap_sync_all.delay().get() From 2717742bd2a66a2757c5e7c2208fd221f9d2c49e Mon Sep 17 00:00:00 2001 From: Jens Langhammer Date: Fri, 5 Feb 2021 15:17:20 +0100 Subject: [PATCH 11/13] sources/ldap: don't remove users from group which were not synced from AD --- authentik/sources/ldap/sync/membership.py | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/authentik/sources/ldap/sync/membership.py b/authentik/sources/ldap/sync/membership.py index 6fe6af870..0fedf793f 100644 --- a/authentik/sources/ldap/sync/membership.py +++ b/authentik/sources/ldap/sync/membership.py @@ -4,6 +4,7 @@ from typing import Any, Optional import ldap3 import ldap3.core.exceptions +from django.db.models import Q from authentik.core.models import Group, User from authentik.sources.ldap.auth import LDAP_DISTINGUISHED_NAME from authentik.sources.ldap.models import LDAPSource @@ -36,13 +37,17 @@ class MembershipLDAPSynchronizer(BaseLDAPSynchronizer): 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 + + users = User.objects.filter( + Q(**{f"attributes__{LDAP_DISTINGUISHED_NAME}__in": members}) | + Q(**{ + f"attributes__{LDAP_DISTINGUISHED_NAME}__isnull": True, + "ak_groups__in": [ak_group] + }) + ) membership_count += 1 membership_count += users.count() ak_group.users.set(users) From 83bf639926a17f36649765304f0418fd97b543dd Mon Sep 17 00:00:00 2001 From: Jens Langhammer Date: Fri, 5 Feb 2021 15:17:57 +0100 Subject: [PATCH 12/13] sources/ldap: use both entryDN and dn (for active-directory) --- authentik/sources/ldap/sync/groups.py | 8 ++++---- authentik/sources/ldap/sync/membership.py | 14 ++++++++------ authentik/sources/ldap/sync/users.py | 3 +-- authentik/sources/ldap/tests/test_sync.py | 3 ++- 4 files changed, 15 insertions(+), 13 deletions(-) diff --git a/authentik/sources/ldap/sync/groups.py b/authentik/sources/ldap/sync/groups.py index 2d71de62f..cfa828c17 100644 --- a/authentik/sources/ldap/sync/groups.py +++ b/authentik/sources/ldap/sync/groups.py @@ -24,7 +24,9 @@ class GroupLDAPSynchronizer(BaseLDAPSynchronizer): group_count = 0 for group in groups: attributes = group.get("attributes", {}) - group_dn = self._flatten(group.get("entryDN", "")) + group_dn = self._flatten( + self._flatten(group.get("entryDN", group.get("dn"))) + ) if self._source.object_uniqueness_field not in attributes: self._logger.warning( "Cannot find uniqueness Field in attributes", @@ -48,8 +50,6 @@ class GroupLDAPSynchronizer(BaseLDAPSynchronizer): }, } ) - self._logger.debug( - "Synced group", group=name, created=created - ) + self._logger.debug("Synced group", group=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 index 0fedf793f..90849a9a2 100644 --- a/authentik/sources/ldap/sync/membership.py +++ b/authentik/sources/ldap/sync/membership.py @@ -3,8 +3,8 @@ from typing import Any, Optional import ldap3 import ldap3.core.exceptions - from django.db.models import Q + from authentik.core.models import Group, User from authentik.sources.ldap.auth import LDAP_DISTINGUISHED_NAME from authentik.sources.ldap.models import LDAPSource @@ -42,11 +42,13 @@ class MembershipLDAPSynchronizer(BaseLDAPSynchronizer): continue users = User.objects.filter( - Q(**{f"attributes__{LDAP_DISTINGUISHED_NAME}__in": members}) | - Q(**{ - f"attributes__{LDAP_DISTINGUISHED_NAME}__isnull": True, - "ak_groups__in": [ak_group] - }) + Q(**{f"attributes__{LDAP_DISTINGUISHED_NAME}__in": members}) + | Q( + **{ + f"attributes__{LDAP_DISTINGUISHED_NAME}__isnull": True, + "ak_groups__in": [ak_group], + } + ) ) membership_count += 1 membership_count += users.count() diff --git a/authentik/sources/ldap/sync/users.py b/authentik/sources/ldap/sync/users.py index fed63b9a1..7506a8cd4 100644 --- a/authentik/sources/ldap/sync/users.py +++ b/authentik/sources/ldap/sync/users.py @@ -28,9 +28,8 @@ class UserLDAPSynchronizer(BaseLDAPSynchronizer): ) user_count = 0 for user in users: - self._logger.debug(user) attributes = user.get("attributes", {}) - user_dn = self._flatten(user.get("entryDN", "")) + user_dn = self._flatten(user.get("entryDN", user.get("dn"))) if self._source.object_uniqueness_field not in attributes: self._logger.warning( "Cannot find uniqueness Field in attributes", diff --git a/authentik/sources/ldap/tests/test_sync.py b/authentik/sources/ldap/tests/test_sync.py index 4c40ab733..1a6a53b2b 100644 --- a/authentik/sources/ldap/tests/test_sync.py +++ b/authentik/sources/ldap/tests/test_sync.py @@ -1,5 +1,4 @@ """LDAP Source tests""" -from authentik.sources.ldap.tests.mock_slapd import mock_slapd_connection from unittest.mock import PropertyMock, patch from django.db.models import Q @@ -14,9 +13,11 @@ 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.mock_ad import mock_ad_connection +from authentik.sources.ldap.tests.mock_slapd import mock_slapd_connection LDAP_PASSWORD = generate_client_secret() + class LDAPSyncTests(TestCase): """LDAP Sync tests""" From 32cf96005313694542a8726a1d41e2bc8b193878 Mon Sep 17 00:00:00 2001 From: Jens Langhammer Date: Sat, 6 Feb 2021 15:24:11 +0100 Subject: [PATCH 13/13] sources/ldap: add property_mappings_group to make group mapping more customisable --- authentik/sources/ldap/api.py | 1 + authentik/sources/ldap/forms.py | 4 ++ authentik/sources/ldap/managed.py | 9 ++++ ...0011_ldapsource_property_mappings_group.py | 24 +++++++++ authentik/sources/ldap/models.py | 7 +++ authentik/sources/ldap/sync/base.py | 52 +++++++++++++++++- authentik/sources/ldap/sync/groups.py | 44 ++++++++------- authentik/sources/ldap/sync/users.py | 54 +++---------------- authentik/sources/ldap/tests/mock_slapd.py | 4 +- authentik/sources/ldap/tests/test_sync.py | 12 ++++- swagger.yaml | 8 +++ 11 files changed, 150 insertions(+), 69 deletions(-) create mode 100644 authentik/sources/ldap/migrations/0011_ldapsource_property_mappings_group.py diff --git a/authentik/sources/ldap/api.py b/authentik/sources/ldap/api.py index 60fb450b0..bd95686e3 100644 --- a/authentik/sources/ldap/api.py +++ b/authentik/sources/ldap/api.py @@ -29,6 +29,7 @@ class LDAPSourceSerializer(ModelSerializer, MetaNameSerializer): "sync_groups", "sync_parent_group", "property_mappings", + "property_mappings_group", ] extra_kwargs = {"bind_password": {"write_only": True}} diff --git a/authentik/sources/ldap/forms.py b/authentik/sources/ldap/forms.py index e89e29d1f..55a48fe1e 100644 --- a/authentik/sources/ldap/forms.py +++ b/authentik/sources/ldap/forms.py @@ -14,6 +14,9 @@ class LDAPSourceForm(forms.ModelForm): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.fields["property_mappings"].queryset = LDAPPropertyMapping.objects.all() + self.fields[ + "property_mappings_group" + ].queryset = LDAPPropertyMapping.objects.all() class Meta: @@ -33,6 +36,7 @@ class LDAPSourceForm(forms.ModelForm): "sync_users_password", "sync_groups", "property_mappings", + "property_mappings_group", "additional_user_dn", "additional_group_dn", "user_object_filter", diff --git a/authentik/sources/ldap/managed.py b/authentik/sources/ldap/managed.py index f53c92df4..49fb289b2 100644 --- a/authentik/sources/ldap/managed.py +++ b/authentik/sources/ldap/managed.py @@ -11,6 +11,7 @@ class LDAPProviderManager(ObjectManager): EnsureExists( LDAPPropertyMapping, "object_field", + "expression", name="authentik default LDAP Mapping: name", object_field="name", expression="return ldap.get('name')", @@ -47,4 +48,12 @@ class LDAPProviderManager(ObjectManager): object_field="username", expression="return ldap.get('uid')", ), + EnsureExists( + LDAPPropertyMapping, + "object_field", + "expression", + name="authentik default OpenLDAP Mapping: cn", + object_field="name", + expression="return ldap.get('cn')", + ), ] diff --git a/authentik/sources/ldap/migrations/0011_ldapsource_property_mappings_group.py b/authentik/sources/ldap/migrations/0011_ldapsource_property_mappings_group.py new file mode 100644 index 000000000..e9c0f147e --- /dev/null +++ b/authentik/sources/ldap/migrations/0011_ldapsource_property_mappings_group.py @@ -0,0 +1,24 @@ +# Generated by Django 3.1.6 on 2021-02-06 14:01 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("authentik_core", "0017_managed"), + ("authentik_sources_ldap", "0010_auto_20210205_1027"), + ] + + operations = [ + migrations.AddField( + model_name="ldapsource", + name="property_mappings_group", + field=models.ManyToManyField( + blank=True, + default=None, + help_text="Property mappings used for group creation/updating.", + to="authentik_core.PropertyMapping", + ), + ), + ] diff --git a/authentik/sources/ldap/models.py b/authentik/sources/ldap/models.py index fd53b9fcb..96dea06f3 100644 --- a/authentik/sources/ldap/models.py +++ b/authentik/sources/ldap/models.py @@ -52,6 +52,13 @@ class LDAPSource(Source): default="objectSid", help_text=_("Field which contains a unique Identifier.") ) + property_mappings_group = models.ManyToManyField( + PropertyMapping, + default=None, + blank=True, + help_text=_("Property mappings used for group creation/updating."), + ) + sync_users = models.BooleanField(default=True) sync_users_password = models.BooleanField( default=True, diff --git a/authentik/sources/ldap/sync/base.py b/authentik/sources/ldap/sync/base.py index 38ce37b62..3463a93e6 100644 --- a/authentik/sources/ldap/sync/base.py +++ b/authentik/sources/ldap/sync/base.py @@ -1,9 +1,12 @@ """Sync LDAP Users and groups into authentik""" from typing import Any +from django.db.models.query import QuerySet from structlog.stdlib import BoundLogger, get_logger -from authentik.sources.ldap.models import LDAPSource +from authentik.core.exceptions import PropertyMappingExpressionException +from authentik.sources.ldap.auth import LDAP_DISTINGUISHED_NAME +from authentik.sources.ldap.models import LDAPPropertyMapping, LDAPSource LDAP_UNIQUENESS = "ldap_uniq" @@ -43,3 +46,50 @@ class BaseLDAPSynchronizer: return None return value[0] return value + + def build_user_properties(self, user_dn: str, **kwargs) -> dict[str, Any]: + """Build attributes for User object based on property mappings.""" + return self._build_object_properties( + user_dn, self._source.property_mappings, **kwargs + ) + + 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 + ) + + def _build_object_properties( + self, object_dn: str, mappings: QuerySet, **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 + ) + if value is None: + continue + object_field = mapping.object_field + if object_field.startswith("attributes."): + # Because returning a list might desired, we can't + # rely on self._flatten here. Instead, just save the result as-is + properties["attributes"][ + object_field.replace("attributes.", "") + ] = value + else: + properties[object_field] = self._flatten(value) + except PropertyMappingExpressionException as exc: + self._logger.warning( + "Mapping failed to evaluate", exc=exc, mapping=mapping + ) + continue + if self._source.object_uniqueness_field in kwargs: + properties["attributes"][LDAP_UNIQUENESS] = self._flatten( + kwargs.get(self._source.object_uniqueness_field) + ) + properties["attributes"][LDAP_DISTINGUISHED_NAME] = object_dn + return properties diff --git a/authentik/sources/ldap/sync/groups.py b/authentik/sources/ldap/sync/groups.py index cfa828c17..149797218 100644 --- a/authentik/sources/ldap/sync/groups.py +++ b/authentik/sources/ldap/sync/groups.py @@ -1,9 +1,9 @@ """Sync LDAP Users and groups into authentik""" import ldap3 import ldap3.core.exceptions +from django.db.utils import IntegrityError 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 @@ -34,22 +34,28 @@ class GroupLDAPSynchronizer(BaseLDAPSynchronizer): dn=group_dn, ) continue - uniq = attributes[self._source.object_uniqueness_field] - # TODO: Use Property Mappings - name = self._flatten(attributes.get("name", "")) - _, created = Group.objects.update_or_create( - **{ - f"attributes__{LDAP_UNIQUENESS}": uniq, - "parent": self._source.sync_parent_group, - "defaults": { - "name": name, - "attributes": { - LDAP_UNIQUENESS: uniq, - LDAP_DISTINGUISHED_NAME: group_dn, - }, - }, - } - ) - self._logger.debug("Synced group", group=name, created=created) - group_count += 1 + uniq = self._flatten(attributes[self._source.object_uniqueness_field]) + try: + defaults = self.build_group_properties(group_dn, **attributes) + self._logger.debug("Creating group with attributes", **defaults) + if "name" not in defaults: + raise IntegrityError("Name was not set by propertymappings") + ak_group, created = Group.objects.update_or_create( + **{ + f"attributes__{LDAP_UNIQUENESS}": uniq, + "parent": self._source.sync_parent_group, + "defaults": defaults, + } + ) + except IntegrityError as exc: + self._logger.warning("Failed to create group", exc=exc) + self._logger.warning( + ( + "To merge new group with existing group, set the group's " + f"Attribute '{LDAP_UNIQUENESS}' to '{uniq}'" + ) + ) + else: + self._logger.debug("Synced group", group=ak_group.name, created=created) + group_count += 1 return group_count diff --git a/authentik/sources/ldap/sync/users.py b/authentik/sources/ldap/sync/users.py index 7506a8cd4..37031b037 100644 --- a/authentik/sources/ldap/sync/users.py +++ b/authentik/sources/ldap/sync/users.py @@ -1,14 +1,9 @@ """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 @@ -39,11 +34,11 @@ class UserLDAPSynchronizer(BaseLDAPSynchronizer): continue uniq = self._flatten(attributes[self._source.object_uniqueness_field]) try: - defaults = self._build_object_properties(user_dn, **attributes) + defaults = self.build_user_properties(user_dn, **attributes) self._logger.debug("Creating user with attributes", **defaults) if "username" not in defaults: raise IntegrityError("Username was not set by propertymappings") - user, created = User.objects.update_or_create( + ak_user, created = User.objects.update_or_create( **{ f"attributes__{LDAP_UNIQUENESS}": uniq, "defaults": defaults, @@ -53,49 +48,16 @@ class UserLDAPSynchronizer(BaseLDAPSynchronizer): self._logger.warning("Failed to create user", exc=exc) self._logger.warning( ( - "To merge new User with existing user, set the User's " + "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=user.username, created=created) + ak_user.set_unusable_password() + ak_user.save() + self._logger.debug( + "Synced User", user=ak_user.username, created=created + ) user_count += 1 return user_count - - def _build_object_properties( - self, user_dn: str, **kwargs - ) -> 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=kwargs, dn=user_dn - ) - if value is None: - continue - object_field = mapping.object_field - if object_field.startswith("attributes."): - # Because returning a list might desired, we can't - # rely on self._flatten here. Instead, just save the result as-is - properties["attributes"][ - object_field.replace("attributes.", "") - ] = value - else: - properties[object_field] = self._flatten(value) - except PropertyMappingExpressionException as exc: - self._logger.warning( - "Mapping failed to evaluate", exc=exc, mapping=mapping - ) - continue - if self._source.object_uniqueness_field in kwargs: - properties["attributes"][LDAP_UNIQUENESS] = self._flatten( - kwargs.get(self._source.object_uniqueness_field) - ) - properties["attributes"][LDAP_DISTINGUISHED_NAME] = user_dn - return properties diff --git a/authentik/sources/ldap/tests/mock_slapd.py b/authentik/sources/ldap/tests/mock_slapd.py index b570f6652..53e733b9b 100644 --- a/authentik/sources/ldap/tests/mock_slapd.py +++ b/authentik/sources/ldap/tests/mock_slapd.py @@ -26,7 +26,7 @@ def mock_slapd_connection(password: str) -> Connection: connection.strategy.add_entry( "cn=group1,ou=groups,dc=goauthentik,dc=io", { - "name": "test-group", + "cn": "group1", "uid": "unique-test-group", "objectClass": "groupOfNames", "member": ["cn=user0,ou=users,dc=goauthentik,dc=io"], @@ -36,7 +36,7 @@ def mock_slapd_connection(password: str) -> Connection: connection.strategy.add_entry( "cn=group2,ou=groups,dc=goauthentik,dc=io", { - "name": "test-group", + "cn": "group2", "objectClass": "groupOfNames", }, ) diff --git a/authentik/sources/ldap/tests/test_sync.py b/authentik/sources/ldap/tests/test_sync.py index 1a6a53b2b..18b80ccb9 100644 --- a/authentik/sources/ldap/tests/test_sync.py +++ b/authentik/sources/ldap/tests/test_sync.py @@ -72,6 +72,11 @@ class LDAPSyncTests(TestCase): | Q(name__startswith="authentik default Active Directory Mapping") ) ) + self.source.property_mappings_group.set( + LDAPPropertyMapping.objects.filter( + name="authentik default LDAP Mapping: name" + ) + ) self.source.save() connection = PropertyMock(return_value=mock_ad_connection(LDAP_PASSWORD)) with patch("authentik.sources.ldap.models.LDAPSource.connection", connection): @@ -92,6 +97,11 @@ class LDAPSyncTests(TestCase): | Q(name__startswith="authentik default OpenLDAP Mapping") ) ) + self.source.property_mappings_group.set( + LDAPPropertyMapping.objects.filter( + name="authentik default OpenLDAP Mapping: cn" + ) + ) self.source.save() connection = PropertyMock(return_value=mock_slapd_connection(LDAP_PASSWORD)) with patch("authentik.sources.ldap.models.LDAPSource.connection", connection): @@ -99,7 +109,7 @@ class LDAPSyncTests(TestCase): group_sync.sync() membership_sync = MembershipLDAPSynchronizer(self.source) membership_sync.sync() - group = Group.objects.filter(name="test-group") + group = Group.objects.filter(name="group1") self.assertTrue(group.exists()) def test_tasks_ad(self): diff --git a/swagger.yaml b/swagger.yaml index 141ca2b4b..ce6844513 100755 --- a/swagger.yaml +++ b/swagger.yaml @@ -9172,6 +9172,14 @@ definitions: type: string format: uuid uniqueItems: true + property_mappings_group: + description: Property mappings used for group creation/updating. + type: array + items: + description: Property mappings used for group creation/updating. + type: string + format: uuid + uniqueItems: true OAuthSource: description: OAuth Source Serializer required: