sources/ldap: implement nested group parentship sync (#19069)

* sources/ldap: add sync_group_parentage field

* sources/ldap: regenerate schema after adding field

* sources/ldap: add group parentage synchronizer

* web/admin: add LDAP source toggle for sync_group_parentage

* sources/ldap: rename LDAPSource fields to more accurate names

* web/admin: update admin UI with new field names

Also added better descriptions to all relevant fields, explaining how they interact with each other.

* sources/ldap: add unit tests

* sources/ldap: fix problem discovered by test

* sources/ldap: lint

* web/admin: lint

* website/docs: update LDAP source docs about lookup fields & membership

* sources/ldap: fix renamed attributes

* web/admin: make web

* sources/ldap: update tests and entries.json fixture

* sources/ldap: update migration with current help_text

* sources/ldap: fix lint error

* sources/ldap: restore membership.py to main, with renamed fields

in prep for separating out the synchronizers

* sources/ldap: abstract get_group into BaseMembershipLDAPSynchronizer

* sources/ldap: add separate ParentshipLDAPSynchronizer

* sources/ldap: fix wrong attribute name in extra parents filtering

* remove renames

* rename `sync_group_parents` to `sync_group_hierarchy`

* set `sync_group_hierarchy` default to `False`

This is a breaking change if it's `True`. It should be changed
eventually, but not in this release.

* revert pagination function change

I'm not entirely sure why, but this change hangs the test
`test_membership_sync_special_chars_in_group_dn`.

* add `sync_group_hierarchy` guard to hierarchy sync

* simplify hierarchy sync

* reword group `parentship` to `hierarchy`

* fix lint

* remove dead code

* move `syncGroupHierarchy` toggle next to similar toggles

---------

Co-authored-by: Simonyi Gergő <28359278+gergosimonyi@users.noreply.github.com>
Co-authored-by: Simonyi Gergő <gergo@goauthentik.io>
This commit is contained in:
Amélie-Laura Lilith Krejčí
2026-07-24 18:04:38 +02:00
committed by GitHub
parent b32135975f
commit e36c628342
14 changed files with 319 additions and 31 deletions

View File

@@ -103,6 +103,7 @@ class LDAPSourceSerializer(SourceSerializer):
"lookup_groups_from_user",
"delete_not_found_objects",
"sync_outgoing_trigger_mode",
"sync_group_hierarchy",
]
extra_kwargs = {"bind_password": {"write_only": True}}
@@ -141,6 +142,7 @@ class LDAPSourceViewSet(UsedByMixin, ModelViewSet):
"group_property_mappings",
"lookup_groups_from_user",
"delete_not_found_objects",
"sync_group_hierarchy",
]
search_fields = ["name", "slug"]
ordering = ["name"]

View File

@@ -0,0 +1,20 @@
# Generated by Django 5.2.15 on 2026-07-24 10:35
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("authentik_sources_ldap", "0011_ldapsource_sync_outgoing_trigger_mode"),
]
operations = [
migrations.AddField(
model_name="ldapsource",
name="sync_group_hierarchy",
field=models.BooleanField(
default=False, help_text="Sync group parentage/hierarchy from LDAP directories."
),
),
]

View File

@@ -144,6 +144,10 @@ class LDAPSource(IncomingSyncSource):
Group, blank=True, null=True, default=None, on_delete=models.SET_DEFAULT
)
sync_group_hierarchy = models.BooleanField(
default=False, help_text=_("Sync group parentage/hierarchy from LDAP directories.")
)
lookup_groups_from_user = models.BooleanField(
default=False,
help_text=_(

View File

@@ -17,8 +17,8 @@ from authentik.sources.ldap.sync.base import BaseLDAPSynchronizer
from authentik.tasks.models import Task
class MembershipLDAPSynchronizer(BaseLDAPSynchronizer):
"""Sync LDAP Users and groups into authentik"""
class BaseMembershipLDAPSynchronizer(BaseLDAPSynchronizer):
"""Sync membership of LDAP Users and Groups into authentik"""
group_cache: dict[str, Group]
@@ -26,6 +26,47 @@ class MembershipLDAPSynchronizer(BaseLDAPSynchronizer):
super().__init__(source, task)
self.group_cache: dict[str, Group] = {}
@staticmethod
def name() -> str:
raise NotImplementedError
def get_objects(self, **kwargs) -> Generator:
raise NotImplementedError
def sync(self, page_data: list) -> int:
raise NotImplementedError
def get_group(self, group_dict: dict[str, Any]) -> Group | None:
"""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._task.info(
f"Group does not have a uniqueness attribute: '{group_dn}'",
group=group_dn,
)
return None
group_uniq = group_uniq[0]
if group_uniq not in self.group_cache:
groups = GroupLDAPSourceConnection.objects.filter(identifier=group_uniq).select_related(
"group"
)
if not groups.exists():
if self._source.sync_groups:
self._task.info(
f"Group does not exist in our DB yet, run sync_groups first: '{group_dn}'",
group=group_dn,
)
return None
self.group_cache[group_uniq] = groups.first().group
return self.group_cache[group_uniq]
class MembershipLDAPSynchronizer(BaseMembershipLDAPSynchronizer):
"""Sync membership of LDAP Users into authentik"""
@staticmethod
def name() -> str:
return "membership"
@@ -94,29 +135,84 @@ class MembershipLDAPSynchronizer(BaseLDAPSynchronizer):
self._logger.debug("Successfully updated group membership")
return membership_count
def get_group(self, group_dict: dict[str, Any]) -> Group | None:
"""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._task.info(
f"Group does not have a uniqueness attribute: '{group_dn}'",
group=group_dn,
class GroupHierarchyLDAPSynchronizer(BaseMembershipLDAPSynchronizer):
"""Sync hierarchy of LDAP Groups into authentik"""
@staticmethod
def name() -> str:
return "group hierarchy"
def get_objects(self, **kwargs) -> Generator:
if not self._source.sync_groups:
self._task.info("Group syncing is disabled for this Source")
return iter(())
if not self._source.sync_group_hierarchy:
self._task.info("Group hierarchy syncing is disabled for this Source")
return iter(())
attributes = [
self._source.group_membership_field,
self._source.user_membership_attribute,
self._source.object_uniqueness_field,
LDAP_DISTINGUISHED_NAME,
]
return self.search_paginator(
search_base=self.base_dn_groups,
search_filter=self._source.group_object_filter,
search_scope=SUBTREE,
attributes=attributes,
**kwargs,
)
def sync(self, page_data: list) -> int:
"""Iterate over all Groups and assign their parents"""
if not self._source.sync_groups:
self._task.info("Group syncing is disabled for this Source")
return -1
if not self._source.sync_group_hierarchy:
self._task.info("Group hierarchy syncing is disabled for this Source")
return -1
count = 0
for group_data in page_data:
if (attributes := self.get_attributes(group_data)) is None:
continue
group = self.get_group(group_data)
if not group:
continue
# Deliberately WET
if self._source.lookup_groups_from_user:
parents_from_source_raw = attributes.get(self._source.group_membership_field, [])
parents_from_source = Group.objects.filter(
**{
(
"attributes__" f"{self._source.user_membership_attribute}__in"
): parents_from_source_raw
}
)
return None
group_uniq = group_uniq[0]
if group_uniq not in self.group_cache:
groups = GroupLDAPSourceConnection.objects.filter(identifier=group_uniq).select_related(
"group"
)
if not groups.exists():
if self._source.sync_groups:
self._task.info(
f"Group does not exist in our DB yet, run sync_groups first: '{group_dn}'",
group=group_dn,
)
return None
self.group_cache[group_uniq] = groups.first().group
return self.group_cache[group_uniq]
parents_not_from_source = group.parents.exclude(
groupsourceconnection__source=self._source
)
count = len(parents_from_source)
group.parents.set(parents_from_source.union(parents_not_from_source))
else:
children_from_source_raw = attributes.get(self._source.group_membership_field, [])
children_from_source = Group.objects.filter(
**{
(
"attributes__" f"{self._source.user_membership_attribute}__in"
): children_from_source_raw
}
)
children_not_from_source = group.children.exclude(
groupsourceconnection__source=self._source
)
count = len(children_from_source)
group.children.set(children_from_source.union(children_not_from_source))
self._logger.debug("Successfully updated group hierarchy")
return count

View File

@@ -21,7 +21,10 @@ from authentik.sources.ldap.sync.base import BaseLDAPSynchronizer
from authentik.sources.ldap.sync.forward_delete_groups import GroupLDAPForwardDeletion
from authentik.sources.ldap.sync.forward_delete_users import UserLDAPForwardDeletion
from authentik.sources.ldap.sync.groups import GroupLDAPSynchronizer
from authentik.sources.ldap.sync.membership import MembershipLDAPSynchronizer
from authentik.sources.ldap.sync.membership import (
GroupHierarchyLDAPSynchronizer,
MembershipLDAPSynchronizer,
)
from authentik.sources.ldap.sync.users import UserLDAPSynchronizer
from authentik.tasks.middleware import CurrentTask
from authentik.tasks.models import Task
@@ -31,6 +34,7 @@ SYNC_CLASSES: list[type[BaseLDAPSynchronizer]] = [
UserLDAPSynchronizer,
GroupLDAPSynchronizer,
MembershipLDAPSynchronizer,
GroupHierarchyLDAPSynchronizer,
]
CACHE_KEY_PREFIX = "goauthentik.io/sources/ldap/page/"
CACHE_KEY_STATUS = "goauthentik.io/sources/ldap/status/"
@@ -71,7 +75,10 @@ def ldap_sync(source_pk: str):
+ ldap_sync_paginator(task, source, GroupLDAPSynchronizer)
)
membership_tasks = group(ldap_sync_paginator(task, source, MembershipLDAPSynchronizer))
membership_tasks = group(
ldap_sync_paginator(task, source, MembershipLDAPSynchronizer)
+ ldap_sync_paginator(task, source, GroupHierarchyLDAPSynchronizer)
)
deletion_tasks = group(
ldap_sync_paginator(task, source, UserLDAPForwardDeletion)

View File

@@ -22,7 +22,10 @@ from authentik.sources.ldap.models import (
)
from authentik.sources.ldap.sync.forward_delete_users import DELETE_CHUNK_SIZE
from authentik.sources.ldap.sync.groups import GroupLDAPSynchronizer
from authentik.sources.ldap.sync.membership import MembershipLDAPSynchronizer
from authentik.sources.ldap.sync.membership import (
GroupHierarchyLDAPSynchronizer,
MembershipLDAPSynchronizer,
)
from authentik.sources.ldap.sync.users import UserLDAPSynchronizer
from authentik.sources.ldap.tasks import ldap_sync, ldap_sync_page
from authentik.sources.ldap.tests.mock_ad import mock_ad_connection
@@ -417,6 +420,102 @@ class LDAPSyncTests(TestCase):
posix_group = Group.objects.filter(name="group-posix").first()
self.assertTrue(posix_group.users.filter(name="user-posix").exists())
def test_sync_group_hierarchy_ad(self):
"""Test group hierarchy sync"""
self.source.base_dn = "dc=t,dc=goauthentik,dc=io"
self.source.additional_user_dn = ""
self.source.additional_group_dn = ""
self.source.sync_group_hierarchy = True
self.source.save()
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"
)
)
connection = MagicMock(return_value=mock_ad_connection())
with patch("authentik.sources.ldap.models.LDAPSource.connection", connection):
_user = create_test_admin_user()
sync_parent_group = Group.objects.get(name=_user.username)
self.source.sync_parent_group = sync_parent_group
self.source.save()
group_sync = GroupLDAPSynchronizer(self.source, Task())
group_sync.sync_full()
hierarchy_sync = GroupHierarchyLDAPSynchronizer(self.source, Task())
hierarchy_sync.sync_full()
child_group_name = "Domain Admins"
parent_group_name = "Administrators"
group: Group = Group.objects.filter(name=child_group_name).first()
parent_ad_group = Group.objects.filter(name=parent_group_name).first()
self.assertIsNotNone(group, f"Child group {child_group_name} not found")
self.assertIsNotNone(parent_ad_group, f"Parent group {parent_group_name} not found")
self.assertTrue(
parent_ad_group in group.parents.all(),
f"Parent group {parent_group_name} not synced as parent of {child_group_name}",
)
self.assertTrue(
sync_parent_group in group.parents.all(),
f"Additional parent group missing from {child_group_name}'s parents",
)
self.assertTrue(
sync_parent_group in parent_ad_group.parents.all(),
f"Additional parent group missing from {parent_group_name}'s parents",
)
def test_sync_group_hierarchy_ad_memberOf(self):
"""Test group hierarchy sync"""
self.source.base_dn = "dc=t,dc=goauthentik,dc=io"
self.source.additional_user_dn = ""
self.source.additional_group_dn = ""
self.source.sync_group_hierarchy = True
self.source.lookup_groups_from_user = True
self.source.group_membership_field = "memberOf"
self.source.save()
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"
)
)
connection = MagicMock(return_value=mock_ad_connection())
with patch("authentik.sources.ldap.models.LDAPSource.connection", connection):
_user = create_test_admin_user()
sync_parent_group = Group.objects.get(name=_user.username)
self.source.sync_parent_group = sync_parent_group
self.source.save()
group_sync = GroupLDAPSynchronizer(self.source, Task())
group_sync.sync_full()
hierarchy_sync = GroupHierarchyLDAPSynchronizer(self.source, Task())
hierarchy_sync.sync_full()
child_group_name = "Domain Admins"
parent_group_name = "Administrators"
group: Group = Group.objects.filter(name=child_group_name).first()
parent_ad_group = Group.objects.filter(name=parent_group_name).first()
self.assertIsNotNone(group, f"Child group {child_group_name} not found")
self.assertIsNotNone(parent_ad_group, f"Parent group {parent_group_name} not found")
self.assertTrue(
parent_ad_group in group.parents.all(),
f"Parent group {parent_group_name} not synced as parent of {child_group_name}",
)
self.assertTrue(
sync_parent_group in group.parents.all(),
f"Additional parent group missing from {child_group_name}'s parents",
)
self.assertTrue(
sync_parent_group in parent_ad_group.parents.all(),
f"Additional parent group missing from {parent_group_name}'s parents",
)
def test_tasks_ad(self):
"""Test Scheduled tasks"""
self.source.user_property_mappings.set(

View File

@@ -13062,6 +13062,11 @@
],
"title": "Sync outgoing trigger mode",
"description": "When to trigger sync for outgoing providers"
},
"sync_group_hierarchy": {
"type": "boolean",
"title": "Sync group hierarchy",
"description": "Sync group parentage/hierarchy from LDAP directories."
}
},
"required": []

View File

@@ -715,6 +715,7 @@ export interface SourcesLdapListRequest {
slug?: string;
sni?: boolean;
startTls?: boolean;
syncGroupHierarchy?: boolean;
syncGroups?: boolean;
syncParentGroup?: string;
syncUsers?: boolean;
@@ -5968,6 +5969,10 @@ export class SourcesApi extends runtime.BaseAPI {
queryParameters["start_tls"] = requestParameters["startTls"];
}
if (requestParameters["syncGroupHierarchy"] != null) {
queryParameters["sync_group_hierarchy"] = requestParameters["syncGroupHierarchy"];
}
if (requestParameters["syncGroups"] != null) {
queryParameters["sync_groups"] = requestParameters["syncGroups"];
}

View File

@@ -288,6 +288,12 @@ export interface LDAPSource {
* @memberof LDAPSource
*/
syncOutgoingTriggerMode?: SyncOutgoingTriggerModeEnum;
/**
* Sync group parentage/hierarchy from LDAP directories.
* @type {boolean}
* @memberof LDAPSource
*/
syncGroupHierarchy?: boolean;
}
/**
@@ -456,6 +462,8 @@ export function LDAPSourceFromJSONTyped(json: any, ignoreDiscriminator: boolean)
json["sync_outgoing_trigger_mode"] == null
? undefined
: SyncOutgoingTriggerModeEnumFromJSON(json["sync_outgoing_trigger_mode"]),
syncGroupHierarchy:
json["sync_group_hierarchy"] == null ? undefined : json["sync_group_hierarchy"],
};
}
@@ -519,5 +527,6 @@ export function LDAPSourceToJSONTyped(
sync_outgoing_trigger_mode: SyncOutgoingTriggerModeEnumToJSON(
value["syncOutgoingTriggerMode"],
),
sync_group_hierarchy: value["syncGroupHierarchy"],
};
}

View File

@@ -238,6 +238,12 @@ export interface LDAPSourceRequest {
* @memberof LDAPSourceRequest
*/
syncOutgoingTriggerMode?: SyncOutgoingTriggerModeEnum;
/**
* Sync group parentage/hierarchy from LDAP directories.
* @type {boolean}
* @memberof LDAPSourceRequest
*/
syncGroupHierarchy?: boolean;
}
/**
@@ -362,6 +368,8 @@ export function LDAPSourceRequestFromJSONTyped(
json["sync_outgoing_trigger_mode"] == null
? undefined
: SyncOutgoingTriggerModeEnumFromJSON(json["sync_outgoing_trigger_mode"]),
syncGroupHierarchy:
json["sync_group_hierarchy"] == null ? undefined : json["sync_group_hierarchy"],
};
}
@@ -415,5 +423,6 @@ export function LDAPSourceRequestToJSONTyped(
sync_outgoing_trigger_mode: SyncOutgoingTriggerModeEnumToJSON(
value["syncOutgoingTriggerMode"],
),
sync_group_hierarchy: value["syncGroupHierarchy"],
};
}

View File

@@ -238,6 +238,12 @@ export interface PatchedLDAPSourceRequest {
* @memberof PatchedLDAPSourceRequest
*/
syncOutgoingTriggerMode?: SyncOutgoingTriggerModeEnum;
/**
* Sync group parentage/hierarchy from LDAP directories.
* @type {boolean}
* @memberof PatchedLDAPSourceRequest
*/
syncGroupHierarchy?: boolean;
}
/**
@@ -348,6 +354,8 @@ export function PatchedLDAPSourceRequestFromJSONTyped(
json["sync_outgoing_trigger_mode"] == null
? undefined
: SyncOutgoingTriggerModeEnumFromJSON(json["sync_outgoing_trigger_mode"]),
syncGroupHierarchy:
json["sync_group_hierarchy"] == null ? undefined : json["sync_group_hierarchy"],
};
}
@@ -401,5 +409,6 @@ export function PatchedLDAPSourceRequestToJSONTyped(
sync_outgoing_trigger_mode: SyncOutgoingTriggerModeEnumToJSON(
value["syncOutgoingTriggerMode"],
),
sync_group_hierarchy: value["syncGroupHierarchy"],
};
}

View File

@@ -23766,6 +23766,10 @@ paths:
name: start_tls
schema:
type: boolean
- in: query
name: sync_group_hierarchy
schema:
type: boolean
- in: query
name: sync_groups
schema:
@@ -42602,6 +42606,9 @@ components:
allOf:
- $ref: '#/components/schemas/SyncOutgoingTriggerModeEnum'
description: When to trigger sync for outgoing providers
sync_group_hierarchy:
type: boolean
description: Sync group parentage/hierarchy from LDAP directories.
required:
- base_dn
- component
@@ -42821,6 +42828,9 @@ components:
allOf:
- $ref: '#/components/schemas/SyncOutgoingTriggerModeEnum'
description: When to trigger sync for outgoing providers
sync_group_hierarchy:
type: boolean
description: Sync group parentage/hierarchy from LDAP directories.
required:
- base_dn
- name
@@ -49918,6 +49928,9 @@ components:
allOf:
- $ref: '#/components/schemas/SyncOutgoingTriggerModeEnum'
description: When to trigger sync for outgoing providers
sync_group_hierarchy:
type: boolean
description: Sync group parentage/hierarchy from LDAP directories.
PatchedLicenseRequest:
type: object
description: License Serializer

View File

@@ -118,6 +118,12 @@ export class LDAPSourceForm extends BaseSourceForm<LDAPSource> {
label=${msg("Sync groups")}
?checked=${this.instance?.syncGroups ?? true}
></ak-switch-input>
<ak-switch-input
name="syncGroupHierarchy"
label=${msg("Sync Group Hierarchy")}
?checked=${this.instance?.syncGroupHierarchy ?? true}
help=${msg("Sync group hierarchy from LDAP directories.")}
></ak-switch-input>
<ak-switch-input
name="deleteNotFoundObjects"
label=${msg("Delete Not Found Objects")}
@@ -243,7 +249,10 @@ export class LDAPSourceForm extends BaseSourceForm<LDAPSource> {
</ak-form-group>
<ak-form-group label="${msg("Additional settings")}">
<div class="pf-c-form">
<ak-form-element-horizontal label=${msg("Parent Group")} name="syncParentGroup">
<ak-form-element-horizontal
label=${msg("Additional Parent Group")}
name="additionalParentGroup"
>
<ak-search-select
.fetchObjects=${async (query?: string): Promise<Group[]> => {
const args: CoreGroupsListRequest = {

View File

@@ -47,6 +47,7 @@ If the LDAP server rejects the TLS handshake, verify that **Server URI**, **Enab
#### Additional settings
- **Sync Group Parents**: Sync group hierarchy from LDAP directories. Adds parents to groups imported from this LDAP directory, using same lookup fields and method as for user group membership.
- **Parent Group**: Parent group for all the groups imported from LDAP. An example use case would be to import Active Directory groups under a root `imported-from-ad` group.
- **User path**: Path template for all new users created.
- **Additional User DN**: Prepended to the base DN for user queries.