diff --git a/authentik/managed/manager.py b/authentik/managed/manager.py index 259259979..e03927af5 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: tuple[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,15 +29,16 @@ 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: + value = self._kwargs.get(field, None) + if value: + update_kwargs[field] = value self._kwargs.setdefault("managed", True) - self._obj.objects.update_or_create( - **{ - self._match_field: matcher_value, - "managed": True, - "defaults": self._kwargs, - } - ) + self._obj.objects.update_or_create(**update_kwargs) class ObjectManager: 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..bd95686e3 100644 --- a/authentik/sources/ldap/api.py +++ b/authentik/sources/ldap/api.py @@ -22,13 +22,14 @@ 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", "sync_groups", "sync_parent_group", "property_mappings", + "property_mappings_group", ] extra_kwargs = {"bind_password": {"write_only": True}} 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..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,11 +36,12 @@ class LDAPSourceForm(forms.ModelForm): "sync_users_password", "sync_groups", "property_mappings", + "property_mappings_group", "additional_user_dn", "additional_group_dn", "user_object_filter", "group_object_filter", - "user_group_membership_field", + "group_membership_field", "object_uniqueness_field", "sync_parent_group", ] @@ -51,7 +55,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/managed.py b/authentik/sources/ldap/managed.py index 37ad59dc9..49fb289b2 100644 --- a/authentik/sources/ldap/managed.py +++ b/authentik/sources/ldap/managed.py @@ -11,7 +11,8 @@ class LDAPProviderManager(ObjectManager): EnsureExists( LDAPPropertyMapping, "object_field", - name="authentik default LDAP Mapping: Name", + "expression", + name="authentik default LDAP Mapping: name", object_field="name", expression="return ldap.get('name')", ), @@ -22,9 +23,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 +39,21 @@ 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')", + ), + 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/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/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/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 83319ec30..96dea06f3 100644 --- a/authentik/sources/ldap/models.py +++ b/authentik/sources/ldap/models.py @@ -38,20 +38,27 @@ class LDAPSource(Source): ) user_object_filter = models.TextField( - default="(objectCategory=Person)", + default="(objectClass=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)", + default="(objectClass=group)", help_text=_("Consider Objects matching this filter to be Groups."), ) object_uniqueness_field = models.TextField( 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/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..3463a93e6 --- /dev/null +++ b/authentik/sources/ldap/sync/base.py @@ -0,0 +1,95 @@ +"""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.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" + + +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) -> 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 + + 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 new file mode 100644 index 000000000..149797218 --- /dev/null +++ b/authentik/sources/ldap/sync/groups.py @@ -0,0 +1,61 @@ +"""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.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", {}) + 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", + attributes=attributes.keys(), + dn=group_dn, + ) + continue + 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/membership.py b/authentik/sources/ldap/sync/membership.py new file mode 100644 index 000000000..90849a9a2 --- /dev/null +++ b/authentik/sources/ldap/sync/membership.py @@ -0,0 +1,86 @@ +"""Sync LDAP Users and groups into authentik""" +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 +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) -> 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, + search_filter=self._source.group_object_filter, + search_scope=ldap3.SUBTREE, + 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, [] + ) + 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) + 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_dn = group_dict.get("attributes", {}).get(LDAP_DISTINGUISHED_NAME, []) + group_uniq = group_dict.get("attributes", {}).get( + self._source.object_uniqueness_field, [] + ) + # 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} + ) + 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..37031b037 --- /dev/null +++ b/authentik/sources/ldap/sync/users.py @@ -0,0 +1,63 @@ +"""Sync LDAP Users into authentik""" +import ldap3 +import ldap3.core.exceptions +from django.db.utils import IntegrityError + +from authentik.core.models import User +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", {}) + 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", + attributes=attributes.keys(), + dn=user_dn, + ) + continue + uniq = self._flatten(attributes[self._source.object_uniqueness_field]) + try: + 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") + ak_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: + 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 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/utils.py b/authentik/sources/ldap/tests/mock_ad.py similarity index 58% rename from authentik/sources/ldap/tests/utils.py rename to authentik/sources/ldap/tests/mock_ad.py index 6eb423ccb..c94519f0f 100644 --- a/authentik/sources/ldap/tests/utils.py +++ b/authentik/sources/ldap/tests/mock_ad.py @@ -3,94 +3,94 @@ 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( 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", + "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", - "memberOf": "cn=group1,ou=groups,DC=AD2012,DC=LAB", - "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..53e733b9b --- /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", + { + "cn": "group1", + "uid": "unique-test-group", + "objectClass": "groupOfNames", + "member": ["cn=user0,ou=users,dc=goauthentik,dc=io"], + }, + ) + # Group without SID + connection.strategy.add_entry( + "cn=group2,ou=groups,dc=goauthentik,dc=io", + { + "cn": "group2", + "objectClass": "groupOfNames", + }, + ) + 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 6fbc69946..92eb7cb52 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 @@ -8,11 +9,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.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=_build_mock_connection(LDAP_PASSWORD)) class LDAPSyncTests(TestCase): @@ -23,27 +24,64 @@ 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""" - syncer = LDAPSynchronizer(self.source) - syncer.sync_users() - - 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") ) + ) + 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 82f497e54..91a32abf2 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.mock_ad 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): @@ -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 8b91839b1..18b80ccb9 100644 --- a/authentik/sources/ldap/tests/test_sync.py +++ b/authentik/sources/ldap/tests/test_sync.py @@ -1,18 +1,21 @@ """LDAP Source tests""" from unittest.mock import PropertyMock, patch +from django.db.models import Q from django.test import TestCase 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.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=_build_mock_connection(LDAP_PASSWORD)) class LDAPSyncTests(TestCase): @@ -23,31 +26,116 @@ 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_sync_users(self): + def test_sync_users_ad(self): """Test user sync""" - syncer = LDAPSynchronizer(self.source) - syncer.sync_users() - 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""" - syncer = LDAPSynchronizer(self.source) - syncer.sync_groups() - syncer.sync_membership() - 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.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): + 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.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): + group_sync = GroupLDAPSynchronizer(self.source) + group_sync.sync() + membership_sync = MembershipLDAPSynchronizer(self.source) + membership_sync.sync() + group = Group.objects.filter(name="group1") + 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() 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/swagger.yaml b/swagger.yaml index 01f2a69d2..ce6844513 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: @@ -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: 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' 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.