diff --git a/authentik/sources/ldap/api.py b/authentik/sources/ldap/api.py
index 16741caa3ed3..bb04682afad7 100644
--- a/authentik/sources/ldap/api.py
+++ b/authentik/sources/ldap/api.py
@@ -99,6 +99,7 @@ class Meta:
"sync_groups",
"sync_parent_group",
"connectivity",
+ "lookup_groups_from_user",
]
extra_kwargs = {"bind_password": {"write_only": True}}
@@ -134,6 +135,7 @@ class LDAPSourceViewSet(UsedByMixin, ModelViewSet):
"sync_parent_group",
"user_property_mappings",
"group_property_mappings",
+ "lookup_groups_from_user",
]
search_fields = ["name", "slug"]
ordering = ["name"]
diff --git a/authentik/sources/ldap/migrations/0007_ldapsource_lookup_groups_from_user.py b/authentik/sources/ldap/migrations/0007_ldapsource_lookup_groups_from_user.py
new file mode 100644
index 000000000000..d2cfce45d686
--- /dev/null
+++ b/authentik/sources/ldap/migrations/0007_ldapsource_lookup_groups_from_user.py
@@ -0,0 +1,24 @@
+# Generated by Django 4.1.2 on 2022-10-31 16:30
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ (
+ "authentik_sources_ldap",
+ "0006_rename_ldappropertymapping_ldapsourcepropertymapping_and_more",
+ ),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="ldapsource",
+ name="lookup_groups_from_user",
+ field=models.BooleanField(
+ default=False,
+ help_text="Lookup group membership based on a user attribute instead of a group attribute.This allows nested group resolution on systems like FreeIPA",
+ ),
+ ),
+ ]
diff --git a/authentik/sources/ldap/models.py b/authentik/sources/ldap/models.py
index dcfa0ccc1ecd..4fe046dfd1aa 100644
--- a/authentik/sources/ldap/models.py
+++ b/authentik/sources/ldap/models.py
@@ -123,6 +123,14 @@ class LDAPSource(Source):
Group, blank=True, null=True, default=None, on_delete=models.SET_DEFAULT
)
+ lookup_groups_from_user = models.BooleanField(
+ default=False,
+ help_text=_(
+ "Lookup group membership based on a user attribute instead of a group attribute."
+ "This allows nested group resolution on systems like FreeIPA and Active Directory"
+ ),
+ )
+
@property
def component(self) -> str:
return "ak-source-ldap-form"
diff --git a/authentik/sources/ldap/sync/membership.py b/authentik/sources/ldap/sync/membership.py
index 853e98fdc6e6..cbeaacbdd1c6 100644
--- a/authentik/sources/ldap/sync/membership.py
+++ b/authentik/sources/ldap/sync/membership.py
@@ -28,15 +28,17 @@ def get_objects(self, **kwargs) -> Generator:
if not self._source.sync_groups:
self.message("Group syncing is disabled for this Source")
return iter(())
+
+ # 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)
+
return self.search_paginator(
search_base=self.base_dn_groups,
search_filter=self._source.group_object_filter,
search_scope=SUBTREE,
- attributes=[
- self._source.group_membership_field,
- self._source.object_uniqueness_field,
- LDAP_DISTINGUISHED_NAME,
- ],
+ attributes=attributes,
**kwargs,
)
@@ -47,9 +49,24 @@ def sync(self, page_data: list) -> int:
return -1
membership_count = 0
for group in page_data:
- if "attributes" not in group:
- continue
- members = group.get("attributes", {}).get(self._source.group_membership_field, [])
+ if self._source.lookup_groups_from_user:
+ group_dn = group.get("dn", {})
+ group_filter = f"({self._source.group_membership_field}={group_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],
+ )
+ members = []
+ for group_member in group_members:
+ group_member_dn = group_member.get("dn", {})
+ members.append(group_member_dn)
+ else:
+ if "attributes" not in group:
+ continue
+ members = group.get("attributes", {}).get(self._source.group_membership_field, [])
+
ak_group = self.get_group(group)
if not ak_group:
continue
@@ -68,7 +85,7 @@ def sync(self, page_data: list) -> int:
"ak_groups__in": [ak_group],
}
)
- )
+ ).distinct()
membership_count += 1
membership_count += users.count()
ak_group.users.set(users)
diff --git a/blueprints/schema.json b/blueprints/schema.json
index 8fd4002d447e..9a6fd52333f1 100644
--- a/blueprints/schema.json
+++ b/blueprints/schema.json
@@ -7478,6 +7478,11 @@
"type": "string",
"format": "uuid",
"title": "Sync parent group"
+ },
+ "lookup_groups_from_user": {
+ "type": "boolean",
+ "title": "Lookup groups from user",
+ "description": "Lookup group membership based on a user attribute instead of a group attribute.This allows nested group resolution on systems like FreeIPA and Active Directory"
}
},
"required": []
diff --git a/schema.yml b/schema.yml
index fbf74d3c91a9..9c698881fd1c 100644
--- a/schema.yml
+++ b/schema.yml
@@ -26595,6 +26595,10 @@ paths:
format: uuid
explode: true
style: form
+ - in: query
+ name: lookup_groups_from_user
+ schema:
+ type: boolean
- in: query
name: name
schema:
@@ -44480,6 +44484,11 @@ components:
nullable: true
description: Get cached source connectivity
readOnly: true
+ lookup_groups_from_user:
+ type: boolean
+ description: Lookup group membership based on a user attribute instead of
+ a group attribute.This allows nested group resolution on systems like
+ FreeIPA and Active Directory
required:
- base_dn
- component
@@ -44676,6 +44685,11 @@ components:
type: string
format: uuid
nullable: true
+ lookup_groups_from_user:
+ type: boolean
+ description: Lookup group membership based on a user attribute instead of
+ a group attribute.This allows nested group resolution on systems like
+ FreeIPA and Active Directory
required:
- base_dn
- name
@@ -49602,6 +49616,11 @@ components:
type: string
format: uuid
nullable: true
+ lookup_groups_from_user:
+ type: boolean
+ description: Lookup group membership based on a user attribute instead of
+ a group attribute.This allows nested group resolution on systems like
+ FreeIPA and Active Directory
PatchedLicenseRequest:
type: object
description: License Serializer
diff --git a/web/src/admin/sources/ldap/LDAPSourceForm.ts b/web/src/admin/sources/ldap/LDAPSourceForm.ts
index 7e4d3e23a7e2..b31eba3bf611 100644
--- a/web/src/admin/sources/ldap/LDAPSourceForm.ts
+++ b/web/src/admin/sources/ldap/LDAPSourceForm.ts
@@ -416,6 +416,28 @@ export class LDAPSourceForm extends BaseSourceForm
+ ${msg( + "Field which contains DNs of groups the user is a member of. This field is used to lookup groups from users, e.g. 'memberOf'. To lookup nested groups in an Active Directory environment use 'memberOf:1.2.840.113556.1.4.1941:'.", + )} +
+