From 32cf96005313694542a8726a1d41e2bc8b193878 Mon Sep 17 00:00:00 2001 From: Jens Langhammer Date: Sat, 6 Feb 2021 15:24:11 +0100 Subject: [PATCH] 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: