From 5d2685341dde885caf3c964323d22f1d67a2867a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Am=C3=A9lie=20Lilith=20Krej=C4=8D=C3=AD?= Date: Thu, 10 Apr 2025 14:37:38 +0200 Subject: [PATCH] sources/ldap: lookup group memberships from user attribute (#12661) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * sources/ldap: add support for group lookups from user * sources/ldap: implement working membership lookups * sources/ldap: add schema changes * sources/ldap: add group membership toggle ui element * sources/ldap: lint changed files * website/docs: add note about lookups to AD docs * Update website/docs/users-sources/sources/directory-sync/active-directory/index.md Co-authored-by: Tana M Berry Signed-off-by: Amélie Krejčí * website/docs: simplify wording of attribute documentation Follows suggestions from @jorhett * sources/ldap: add missing spaces in docstrings Follows suggestions from @jorhett * Add a test for memberof attribute * sources/ldap: implement test * format Signed-off-by: Jens Langhammer * re-migrate Signed-off-by: Jens Langhammer * revert website changes in favor of #13966 Signed-off-by: Marc 'risson' Schmitt * update frontend help text Signed-off-by: Marc 'risson' Schmitt --------- Signed-off-by: Amélie Krejčí Signed-off-by: Jens Langhammer Signed-off-by: Marc 'risson' Schmitt Co-authored-by: Shawn Weeks Co-authored-by: Tana M Berry Co-authored-by: Jo Rhett Co-authored-by: Jens Langhammer Co-authored-by: Marc 'risson' Schmitt --- authentik/sources/ldap/api.py | 2 + ...0007_ldapsource_lookup_groups_from_user.py | 24 ++++++++++++ authentik/sources/ldap/models.py | 8 ++++ authentik/sources/ldap/sync/membership.py | 35 +++++++++++++----- authentik/sources/ldap/tests/mock_freeipa.py | 20 ++++++++++ authentik/sources/ldap/tests/test_sync.py | 37 +++++++++++++++++++ blueprints/schema.json | 5 +++ schema.yml | 19 ++++++++++ web/src/admin/sources/ldap/LDAPSourceForm.ts | 24 +++++++++++- 9 files changed, 164 insertions(+), 10 deletions(-) create mode 100644 authentik/sources/ldap/migrations/0007_ldapsource_lookup_groups_from_user.py diff --git a/authentik/sources/ldap/api.py b/authentik/sources/ldap/api.py index 16741caa3e..bb04682afa 100644 --- a/authentik/sources/ldap/api.py +++ b/authentik/sources/ldap/api.py @@ -99,6 +99,7 @@ class LDAPSourceSerializer(SourceSerializer): "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 0000000000..579e349de3 --- /dev/null +++ b/authentik/sources/ldap/migrations/0007_ldapsource_lookup_groups_from_user.py @@ -0,0 +1,24 @@ +# Generated by Django 5.0.13 on 2025-03-26 17:06 + +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 and Active Directory", + ), + ), + ] diff --git a/authentik/sources/ldap/models.py b/authentik/sources/ldap/models.py index dcfa0ccc1e..2bfbc03e44 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 853e98fdc6..cbeaacbdd1 100644 --- a/authentik/sources/ldap/sync/membership.py +++ b/authentik/sources/ldap/sync/membership.py @@ -28,15 +28,17 @@ class MembershipLDAPSynchronizer(BaseLDAPSynchronizer): 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 @@ class MembershipLDAPSynchronizer(BaseLDAPSynchronizer): 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 @@ class MembershipLDAPSynchronizer(BaseLDAPSynchronizer): "ak_groups__in": [ak_group], } ) - ) + ).distinct() membership_count += 1 membership_count += users.count() ak_group.users.set(users) diff --git a/authentik/sources/ldap/tests/mock_freeipa.py b/authentik/sources/ldap/tests/mock_freeipa.py index f2bb8bb7ba..41639206d8 100644 --- a/authentik/sources/ldap/tests/mock_freeipa.py +++ b/authentik/sources/ldap/tests/mock_freeipa.py @@ -96,6 +96,26 @@ def mock_freeipa_connection(password: str) -> Connection: "objectClass": "posixAccount", }, ) + # User with groups in memberOf attribute + connection.strategy.add_entry( + "cn=user4,ou=users,dc=goauthentik,dc=io", + { + "name": "user4_sn", + "uid": "user4_sn", + "objectClass": "person", + "memberOf": [ + "cn=reverse-lookup-group,ou=groups,dc=goauthentik,dc=io", + ], + }, + ) + connection.strategy.add_entry( + "cn=reverse-lookup-group,ou=groups,dc=goauthentik,dc=io", + { + "cn": "reverse-lookup-group", + "uid": "reverse-lookup-group", + "objectClass": "groupOfNames", + }, + ) # Locked out user connection.strategy.add_entry( "cn=user-nsaccountlock,ou=users,dc=goauthentik,dc=io", diff --git a/authentik/sources/ldap/tests/test_sync.py b/authentik/sources/ldap/tests/test_sync.py index 42c8bea471..951b5805b8 100644 --- a/authentik/sources/ldap/tests/test_sync.py +++ b/authentik/sources/ldap/tests/test_sync.py @@ -162,6 +162,43 @@ class LDAPSyncTests(TestCase): self.assertFalse(User.objects.filter(username="user1_sn").exists()) self.assertFalse(User.objects.get(username="user-nsaccountlock").is_active) + 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.user_property_mappings.set( + LDAPSourcePropertyMapping.objects.filter( + Q(managed__startswith="goauthentik.io/sources/ldap/default") + | Q(managed__startswith="goauthentik.io/sources/ldap/openldap") + ) + ) + self.source.group_property_mappings.set( + LDAPSourcePropertyMapping.objects.filter( + managed="goauthentik.io/sources/ldap/openldap-cn" + ) + ) + connection = MagicMock(return_value=mock_freeipa_connection(LDAP_PASSWORD)) + with patch("authentik.sources.ldap.models.LDAPSource.connection", connection): + user_sync = UserLDAPSynchronizer(self.source) + user_sync.sync_full() + group_sync = GroupLDAPSynchronizer(self.source) + group_sync.sync_full() + membership_sync = MembershipLDAPSynchronizer(self.source) + membership_sync.sync_full() + + self.assertTrue( + User.objects.filter(username="user4_sn").exists(), "User does not exist" + ) + # Test if membership mapping based on memberOf works. + memberof_group = Group.objects.filter(name="reverse-lookup-group") + self.assertTrue(memberof_group.exists(), "Group does not exist") + self.assertTrue( + memberof_group.first().users.filter(username="user4_sn").exists(), + "User not a member of the group", + ) + def test_sync_groups_ad(self): """Test group sync""" self.source.user_property_mappings.set( diff --git a/blueprints/schema.json b/blueprints/schema.json index 650e34576e..9ff0cb00c2 100644 --- a/blueprints/schema.json +++ b/blueprints/schema.json @@ -7885,6 +7885,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 bfc14e6a76..57ef35c42f 100644 --- a/schema.yml +++ b/schema.yml @@ -27649,6 +27649,10 @@ paths: format: uuid explode: true style: form + - in: query + name: lookup_groups_from_user + schema: + type: boolean - in: query name: name schema: @@ -46345,6 +46349,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 @@ -46541,6 +46550,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 @@ -51664,6 +51678,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 7e4d3e23a7..a2e7fc906f 100644 --- a/web/src/admin/sources/ldap/LDAPSourceForm.ts +++ b/web/src/admin/sources/ldap/LDAPSourceForm.ts @@ -412,7 +412,29 @@ export class LDAPSourceForm extends BaseSourceForm { />

${msg( - "Field which contains members of a group. Note that if using the \"memberUid\" field, the value is assumed to contain a relative distinguished name. e.g. 'memberUid=some-user' instead of 'memberUid=cn=some-user,ou=groups,...'", + "Field which contains members of a group. Note that if using the \"memberUid\" field, the value is assumed to contain a relative distinguished name. e.g. 'memberUid=some-user' instead of 'memberUid=cn=some-user,ou=groups,...'. When selecting 'Lookup using a user attribute', this should be a user attribute, otherwise a group attribute.", + )} +

+ + + +

+ ${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:'.", )}