Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 10 additions & 8 deletions authentik/sources/ldap/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -103,18 +103,19 @@ class Meta:
"additional_group_dn",
"user_object_filter",
"group_object_filter",
"group_membership_field",
"user_membership_attribute",
"membership_field",
"membership_reference",
"object_uniqueness_field",
"password_login_update_internal_password",
"sync_users",
"sync_users_password",
"sync_groups",
"sync_parent_group",
"additional_parent_group",
"connectivity",
"lookup_groups_from_user",
"lookup_groups_from_member",
"delete_not_found_objects",
"sync_outgoing_trigger_mode",
"sync_group_parents",
]
extra_kwargs = {"bind_password": {"write_only": True}}

Expand All @@ -141,18 +142,19 @@ class LDAPSourceViewSet(UsedByMixin, ModelViewSet):
"additional_group_dn",
"user_object_filter",
"group_object_filter",
"group_membership_field",
"user_membership_attribute",
"membership_field",
"membership_reference",
"object_uniqueness_field",
"password_login_update_internal_password",
"sync_users",
"sync_users_password",
"sync_groups",
"sync_parent_group",
"additional_parent_group",
"user_property_mappings",
"group_property_mappings",
"lookup_groups_from_user",
"lookup_groups_from_member",
"delete_not_found_objects",
"sync_group_parents",
]
search_fields = ["name", "slug"]
ordering = ["name"]
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
# Generated by Django 5.2.9 on 2025-12-26 17:44

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
("authentik_sources_ldap", "0011_ldapsource_sync_outgoing_trigger_mode"),
]

operations = [
migrations.RenameField(
model_name="ldapsource",
old_name="sync_parent_group",
new_name="additional_parent_group",
),
migrations.RenameField(
model_name="ldapsource",
old_name="lookup_groups_from_user",
new_name="lookup_groups_from_member",
),
migrations.RenameField(
model_name="ldapsource",
old_name="group_membership_field",
new_name="membership_field",
),
migrations.RenameField(
model_name="ldapsource",
old_name="user_membership_attribute",
new_name="membership_reference",
),
migrations.AddField(
model_name="ldapsource",
name="sync_group_parents",
field=models.BooleanField(
default=True, help_text="Sync group parentage/hierarchy from LDAP directories."
),
),
]
17 changes: 10 additions & 7 deletions authentik/sources/ldap/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -102,12 +102,12 @@ class LDAPSource(IncomingSyncSource):
default="(objectClass=person)",
help_text=_("Consider Objects matching this filter to be Users."),
)
user_membership_attribute = models.TextField(
membership_reference = models.TextField(
default=LDAP_DISTINGUISHED_NAME,
help_text=_("Attribute which matches the value of `group_membership_field`."),
help_text=_("Attribute which matches the value of `membership_field`."),
)
group_membership_field = models.TextField(
default="member", help_text=_("Field which contains members of a group.")
membership_field = models.TextField(
default="member", help_text=_("Field which contains a list of members/memberships.")
)
group_object_filter = models.TextField(
default="(objectClass=group)",
Expand All @@ -131,11 +131,14 @@ class LDAPSource(IncomingSyncSource):
),
)
sync_groups = models.BooleanField(default=True)
sync_parent_group = models.ForeignKey(
additional_parent_group = models.ForeignKey(
Group, blank=True, null=True, default=None, on_delete=models.SET_DEFAULT
)
sync_group_parents = models.BooleanField(
default=True, help_text=_("Sync group parentage/hierarchy from LDAP directories.")
)

lookup_groups_from_user = models.BooleanField(
lookup_groups_from_member = models.BooleanField(
default=False,
help_text=_(
"Lookup group membership based on a user attribute instead of a group attribute. "
Expand Down Expand Up @@ -202,7 +205,7 @@ def get_base_user_properties(self, **kwargs):
def get_base_group_properties(self, **kwargs):
return self.update_properties_with_uniqueness_field(
{
"parent": self.sync_parent_group,
"parent": self.additional_parent_group,
},
**kwargs,
)
Expand Down
58 changes: 42 additions & 16 deletions authentik/sources/ldap/sync/membership.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,8 @@ def get_objects(self, **kwargs) -> Generator:

# If we are looking up groups from users, we don't need to fetch the group membership field
attributes = [self._source.object_uniqueness_field, LDAP_DISTINGUISHED_NAME]
if not self._source.lookup_groups_from_user:
attributes.append(self._source.group_membership_field)
if not self._source.lookup_groups_from_member:
attributes.append(self._source.membership_field)

return self.search_paginator(
search_base=self.base_dn_groups,
Expand All @@ -51,41 +51,67 @@ def sync(self, page_data: list) -> int:
return -1
membership_count = 0
for group in page_data:
if self._source.lookup_groups_from_user:
if self._source.lookup_groups_from_member:
group_dn = group.get("dn", {})
escaped_dn = escape_filter_chars(group_dn)
group_filter = f"({self._source.group_membership_field}={escaped_dn})"
group_members = self._source.connection().extend.standard.paged_search(
search_base=self.base_dn_users,
search_filter=group_filter,
search_scope=SUBTREE,
attributes=[self._source.object_uniqueness_field],
)
group_filter = f"({self._source.membership_field}={escaped_dn})"

bases = [self.base_dn_users] # select search bases
if self._source.sync_group_parents:
bases.append(self.base_dn_groups)

group_members = map(
lambda base: self._source.connection().extend.standard.paged_search(
search_base=base,
search_filter=group_filter,
search_scope=SUBTREE,
attributes=[self._source.object_uniqueness_field],
),
bases,
) # do it once or twice depending on sync_group_parents

members = []
for group_member in group_members:
group_member_dn = group_member.get("dn", {})
members.append(group_member_dn)

for per_base_search in group_members: # iterate over results per base
for group_member in per_base_search:
group_member_dn = group_member.get("dn", {})
members.append(group_member_dn)

else:
if (attributes := self.get_attributes(group)) is None:
continue
members = attributes.get(self._source.group_membership_field, [])
members = attributes.get(self._source.membership_field, [])

ak_group = self.get_group(group)
if not ak_group:
continue

users = User.objects.filter(
Q(**{f"attributes__{self._source.user_membership_attribute}__in": members})
Q(**{f"attributes__{self._source.membership_reference}__in": members})
| Q(
**{
f"attributes__{self._source.user_membership_attribute}__isnull": True,
f"attributes__{self._source.membership_reference}__isnull": True,
"ak_groups__in": [ak_group],
}
)
).distinct()
membership_count += 1
membership_count += users.count()
ak_group.users.set(users)

if self._source.sync_group_parents:
groups = Group.objects.filter(
Q(**{f"attributes__{self._source.membership_reference}__in": members})
| Q(
**{
f"attributes__{self._source.membership_reference}__isnull": True,
"parents__in": [ak_group],
}
)
).distinct()
membership_count += groups.count()
ak_group.children.set(groups)

ak_group.save()
self._logger.debug("Successfully updated group membership")
return membership_count
Expand Down
11 changes: 11 additions & 0 deletions authentik/sources/ldap/tests/mock_ad.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ def mock_ad_connection(password: str) -> Connection:
"objectClass": "group",
"distinguishedName": "cn=group1,ou=groups,dc=goauthentik,dc=io",
"member": ["cn=user,ou=users,dc=goauthentik,dc=io"],
"memberOf": ["cn=group3,ou=groups,dc=goauthentik,dc=io"],
},
)
# Group without SID
Expand Down Expand Up @@ -98,5 +99,15 @@ def mock_ad_connection(password: str) -> Connection:
"distinguishedName": "cn=user3,ou=users,dc=goauthentik,dc=io",
},
)
connection.strategy.add_entry(
"cn=group3,ou=groups,dc=goauthentik,dc=io",
{
"name": "test-group-containing-groups",
"objectSid": "nested-test-group",
"objectClass": "group",
"distinguishedName": "cn=group3,ou=groups,dc=goauthentik,dc=io",
"member": ["cn=group1,ou=groups,dc=goauthentik,dc=io"],
},
)
connection.bind()
return connection
93 changes: 84 additions & 9 deletions authentik/sources/ldap/tests/test_sync.py
Original file line number Diff line number Diff line change
Expand Up @@ -178,8 +178,8 @@ def test_sync_groups_freeipa_memberOf(self):
"""Test group sync when membership is derived from memberOf user attribute"""
self.source.object_uniqueness_field = "uid"
self.source.group_object_filter = "(objectClass=groupOfNames)"
self.source.lookup_groups_from_user = True
self.source.group_membership_field = "memberOf"
self.source.lookup_groups_from_member = True
self.source.membership_field = "memberOf"
self.source.user_property_mappings.set(
LDAPSourcePropertyMapping.objects.filter(
Q(managed__startswith="goauthentik.io/sources/ldap/default")
Expand Down Expand Up @@ -225,10 +225,11 @@ def test_sync_groups_ad(self):
)
)
connection = MagicMock(return_value=mock_ad_connection(LDAP_PASSWORD))
self.source.sync_group_parents = False
with patch("authentik.sources.ldap.models.LDAPSource.connection", connection):
_user = create_test_admin_user()
parent_group = Group.objects.get(name=_user.username)
self.source.sync_parent_group = parent_group
self.source.additional_parent_group = parent_group
self.source.save()
group_sync = GroupLDAPSynchronizer(self.source, Task())
group_sync.sync_full()
Expand Down Expand Up @@ -266,10 +267,10 @@ def test_sync_groups_openldap(self):
def test_sync_groups_openldap_posix_group(self):
"""Test posix group sync"""
self.source.object_uniqueness_field = "cn"
self.source.group_membership_field = "memberUid"
self.source.membership_field = "memberUid"
self.source.user_object_filter = "(objectClass=posixAccount)"
self.source.group_object_filter = "(objectClass=posixGroup)"
self.source.user_membership_attribute = "uid"
self.source.membership_reference = "uid"
self.source.user_property_mappings.set(
[
*LDAPSourcePropertyMapping.objects.filter(
Expand Down Expand Up @@ -303,10 +304,10 @@ def test_sync_groups_openldap_posix_group(self):
def test_sync_groups_openldap_posix_group_nonstandard_membership_attribute(self):
"""Test posix group sync"""
self.source.object_uniqueness_field = "cn"
self.source.group_membership_field = "memberUid"
self.source.membership_field = "memberUid"
self.source.user_object_filter = "(objectClass=posixAccount)"
self.source.group_object_filter = "(objectClass=posixGroup)"
self.source.user_membership_attribute = "cn"
self.source.membership_reference = "cn"
self.source.user_property_mappings.set(
[
*LDAPSourcePropertyMapping.objects.filter(
Expand Down Expand Up @@ -337,6 +338,80 @@ def test_sync_groups_openldap_posix_group_nonstandard_membership_attribute(self)
posix_group = Group.objects.filter(name="group-posix").first()
self.assertTrue(posix_group.users.filter(name="user-posix").exists())

def test_sync_group_parentship_ad(self):
"""Test group parentship sync"""
self.source.user_property_mappings.set(
LDAPSourcePropertyMapping.objects.filter(
Q(managed__startswith="goauthentik.io/sources/ldap/default")
| Q(managed__startswith="goauthentik.io/sources/ldap/ms")
)
)
self.source.group_property_mappings.set(
LDAPSourcePropertyMapping.objects.filter(
managed="goauthentik.io/sources/ldap/default-name"
)
)
self.source.sync_group_parents = True
connection = MagicMock(return_value=mock_ad_connection(LDAP_PASSWORD))
with patch("authentik.sources.ldap.models.LDAPSource.connection", connection):
_user = create_test_admin_user()
additional_parent_group = Group.objects.get(name=_user.username)
self.source.additional_parent_group = additional_parent_group
self.source.save()
group_sync = GroupLDAPSynchronizer(self.source, Task())
group_sync.sync_full()
membership_sync = MembershipLDAPSynchronizer(self.source, Task())
membership_sync.sync_full()
group: Group = Group.objects.filter(name="test-group").first()
parent_ad_group = Group.objects.filter(name="test-group-containing-groups").first()
self.assertTrue(parent_ad_group in group.parents.all(), "Parent AD group missing")
self.assertTrue(
additional_parent_group in group.parents.all(),
"Additional parent group missing from test-group",
)
self.assertTrue(
additional_parent_group in parent_ad_group.parents.all(),
"Additional parent group missing from test-group-containing-groups",
)

def test_sync_group_parentship_ad_memberOf(self):
"""Test group parentship sync"""
self.source.user_property_mappings.set(
LDAPSourcePropertyMapping.objects.filter(
Q(managed__startswith="goauthentik.io/sources/ldap/default")
| Q(managed__startswith="goauthentik.io/sources/ldap/ms")
)
)
self.source.group_property_mappings.set(
LDAPSourcePropertyMapping.objects.filter(
managed="goauthentik.io/sources/ldap/default-name"
)
)
self.source.sync_group_parents = True
self.source.lookup_groups_from_member = True
self.source.membership_field = "memberOf"
connection = MagicMock(return_value=mock_ad_connection(LDAP_PASSWORD))
with patch("authentik.sources.ldap.models.LDAPSource.connection", connection):
_user = create_test_admin_user()
additional_parent_group = Group.objects.get(name=_user.username)
self.source.additional_parent_group = additional_parent_group
self.source.save()
group_sync = GroupLDAPSynchronizer(self.source, Task())
group_sync.sync_full()
membership_sync = MembershipLDAPSynchronizer(self.source, Task())
membership_sync.sync_full()
group: Group = Group.objects.filter(name="test-group").first()
parent_ad_group = Group.objects.filter(name="test-group-containing-groups").first()
self.assertTrue(parent_ad_group in group.parents.all(), "Parent AD group missing.")
self.assertTrue(
additional_parent_group in group.parents.all(),
"Additional parent group missing from test-group",
)
self.assertTrue(
additional_parent_group in parent_ad_group.parents.all(),
"Additional parent group missing from test-group-containing-groups",
)

def test_tasks_ad(self):
"""Test Scheduled tasks"""
self.source.user_property_mappings.set(
Expand Down Expand Up @@ -526,8 +601,8 @@ def test_membership_sync_special_chars_in_group_dn(self):
"""Test membership synchronization with special characters in group DN"""
self.source.object_uniqueness_field = "uid"
self.source.group_object_filter = "(objectClass=groupOfNames)"
self.source.lookup_groups_from_user = True
self.source.group_membership_field = "memberOf"
self.source.lookup_groups_from_member = True
self.source.membership_field = "memberOf"

# Mock connection with group DN containing special characters
mock_conn = MagicMock()
Expand Down
Loading
Loading