Repository: goauthentik/authentik

Issue:
ldap group membership not updated
**Is your feature request related to a problem? Please describe.**
The ldap syncing code assumes group membership is based on DN.
In our ldap tree this is unfortunately not the case. Instead the group membership is based on CN.
So while users and groups are synced, the group memberships are not established in authentik's database.

**Describe the solution you'd like**
I need the 'group membership field' to be matched against the CN of a user instead of it's fully qualified DN.

**Additional context**
Example pseudo ldif for groups:
```
dn: cn=group-1,ou=groups,o=example,c=ch
objectClass: posixGroup
cn: group-1
memberUid: user-1
memberUid: user-2
memberUid: user-3

dn: cn=group-2,ou=groups,o=example,c=ch
objectClass: posixGroup
cn: group-2
memberUid: user-1
memberUid: user-2
```
And for users:

```
dn: cn=user-1,ou=users,o=example,c=ch
objectClass: posixAccount
cn: user-1
ou: group-1
ou: group-2

dn: cn=user-2,ou=users,o=example,c=ch
objectClass: posixAccount
cn: user-2
ou: group-1
ou: group-2

dn: cn=user-3,ou=users,o=example,c=ch
objectClass: posixAccount
cn: user-3
ou: group-1
```

If think for the current authentik sync code to work the groups would have to look like this:
```
dn: cn=group-1,ou=groups,o=example,c=ch
objectClass: posixGroup
cn: group-1
memberUid: user-1,ou=users,o=example,c=ch
memberUid: user-2,ou=users,o=example,c=ch
memberUid: user-3,ou=users,o=example,c=ch

dn: cn=group-2,ou=groups,o=example,c=ch
objectClass: posixGroup
cn: group-2
memberUid: user-1,ou=users,o=example,c=ch
memberUid: user-2,ou=users,o=example,c=ch
```

But changing this is beyond my control.

This is the OpenLDAP directory tree of a large University. I'm not sure if this setup and therefor also the problem is common or unique to our deployment.

I guess for the membership mapping to work I'd need the code that authentik uses to lookup group memberships in ./authentik/sources/ldap/sync/membership.py to look something like this:

```
LDAP_UNIQUENESS = "ldap_uniq"

            users = User.objects.filter(
                Q(**{f"attributes__{LDAP_UNIQUENESS}__in": members})
                | Q(
                    **{
                        f"attributes__{LDAP_UNIQUENESS}__isnull": True,
                        "ak_groups__in": [ak_group],
                    }
                )
            )

```

Instead of the current:

```
LDAP_DISTINGUISHED_NAME = "distinguishedName"

            users = User.objects.filter(
                Q(**{f"attributes__{LDAP_DISTINGUISHED_NAME}__in": members})
                | Q(
                    **{
                        f"attributes__{LDAP_DISTINGUISHED_NAME}__isnull": True,
                        "ak_groups__in": [ak_group],
                    }
                )
            )

```

I'm happy to provide a PR after discussing the right approach on how to make this configurable.
ldap group membership not updated
**Is your feature request related to a problem? Please describe.**
The ldap syncing code assumes group membership is based on DN.
In our ldap tree this is unfortunately not the case. Instead the group membership is based on CN.
So while users and groups are synced, the group memberships are not established in authentik's database.

**Describe the solution you'd like**
I need the 'group membership field' to be matched against the CN of a user instead of it's fully qualified DN.

**Additional context**
Example pseudo ldif for groups:
```
dn: cn=group-1,ou=groups,o=example,c=ch
objectClass: posixGroup
cn: group-1
memberUid: user-1
memberUid: user-2
memberUid: user-3

dn: cn=group-2,ou=groups,o=example,c=ch
objectClass: posixGroup
cn: group-2
memberUid: user-1
memberUid: user-2
```
And for users:

```
dn: cn=user-1,ou=users,o=example,c=ch
objectClass: posixAccount
cn: user-1
ou: group-1
ou: group-2

dn: cn=user-2,ou=users,o=example,c=ch
objectClass: posixAccount
cn: user-2
ou: group-1
ou: group-2

dn: cn=user-3,ou=users,o=example,c=ch
objectClass: posixAccount
cn: user-3
ou: group-1
```

If think for the current authentik sync code to work the groups would have to look like this:
```
dn: cn=group-1,ou=groups,o=example,c=ch
objectClass: posixGroup
cn: group-1
memberUid: user-1,ou=users,o=example,c=ch
memberUid: user-2,ou=users,o=example,c=ch
memberUid: user-3,ou=users,o=example,c=ch

dn: cn=group-2,ou=groups,o=example,c=ch
objectClass: posixGroup
cn: group-2
memberUid: user-1,ou=users,o=example,c=ch
memberUid: user-2,ou=users,o=example,c=ch
```

But changing this is beyond my control.

This is the OpenLDAP directory tree of a large University. I'm not sure if this setup and therefor also the problem is common or unique to our deployment.

I guess for the membership mapping to work I'd need the code that authentik uses to lookup group memberships in ./authentik/sources/ldap/sync/membership.py to look something like this:

```
LDAP_UNIQUENESS = "ldap_uniq"

            users = User.objects.filter(
                Q(**{f"attributes__{LDAP_UNIQUENESS}__in": members})
                | Q(
                    **{
                        f"attributes__{LDAP_UNIQUENESS}__isnull": True,
                        "ak_groups__in": [ak_group],
                    }
                )
            )

```

Instead of the current:

```
LDAP_DISTINGUISHED_NAME = "distinguishedName"

            users = User.objects.filter(
                Q(**{f"attributes__{LDAP_DISTINGUISHED_NAME}__in": members})
                | Q(
                    **{
                        f"attributes__{LDAP_DISTINGUISHED_NAME}__isnull": True,
                        "ak_groups__in": [ak_group],
                    }
                )
            )

```

I'm happy to provide a PR after discussing the right approach on how to make this configurable.


Discussion:
Would you accept a PR with something like the code in this branch?
https://github.com/asteven/authentik/tree/issue-1436
Reading up on the topic it seems this is basically a question of which LDAP objectClass is used for managing group memberships (e.g. posixGroup with the attribute 'memberUid' vs groupOfNames with the attribute 'member')

Some information on the topic:
https://unofficialaciguide.com/2019/07/31/ldap-schemas-for-aci-administrators-rfc2307-vs-rfc2307bis/


Here's how some software handles these differences:

### sssd
If ldap_schema is set to rfc2307bis only the member attribute is considered and the value is assumed to be the FDN

### OwnCloud
https://doc.owncloud.com/server/next/admin_manual/configuration/user/user_auth_ldap.html
- member with FDN for Active Directory or for objectclass groupOfNames groups
- memberUid with RDN for objectclass posixGroup groups
- uniqueMember with FDN for objectclass groupOfUniqueNames groups


A pragmatic solution might be to just handle the membership resolution based on RDN if the 'Group membership field' is set to 'memberUid' and use the FDN (as it is now) in any other case.

So in the meantime I have 2 branches that solve this problem for me:

https://github.com/asteven/authentik/tree/issue-1436
New boolean field in the ldap source model that causes the membership sync code to use RDN instead of FDN to assign group members.

https://github.com/asteven/authentik/tree/memberUid-rdn
Use RDN if the 'Group membership field' is 'memberUid', use FDN otherwise.

I guess the second is somewhat to magical.

@BeryJu Any preference?
Sorry for taking so long to answer this issue, I'm partial to https://github.com/asteven/authentik/tree/memberUid-rdn. I prefer not having to add another field to the already (imo) too large LDAP form. As long as the special logic for this value is documented it should be fine imo.
Would you accept a PR with something like the code in this branch?
https://github.com/asteven/authentik/tree/issue-1436
Reading up on the topic it seems this is basically a question of which LDAP objectClass is used for managing group memberships (e.g. posixGroup with the attribute 'memberUid' vs groupOfNames with the attribute 'member')

Some information on the topic:
https://unofficialaciguide.com/2019/07/31/ldap-schemas-for-aci-administrators-rfc2307-vs-rfc2307bis/


Here's how some software handles these differences:

### sssd
If ldap_schema is set to rfc2307bis only the member attribute is considered and the value is assumed to be the FDN

### OwnCloud
https://doc.owncloud.com/server/next/admin_manual/configuration/user/user_auth_ldap.html
- member with FDN for Active Directory or for objectclass groupOfNames groups
- memberUid with RDN for objectclass posixGroup groups
- uniqueMember with FDN for objectclass groupOfUniqueNames groups


A pragmatic solution might be to just handle the membership resolution based on RDN if the 'Group membership field' is set to 'memberUid' and use the FDN (as it is now) in any other case.

So in the meantime I have 2 branches that solve this problem for me:

https://github.com/asteven/authentik/tree/issue-1436
New boolean field in the ldap source model that causes the membership sync code to use RDN instead of FDN to assign group members.

https://github.com/asteven/authentik/tree/memberUid-rdn
Use RDN if the 'Group membership field' is 'memberUid', use FDN otherwise.

I guess the second is somewhat to magical.

@BeryJu Any preference?
Sorry for taking so long to answer this issue, I'm partial to https://github.com/asteven/authentik/tree/memberUid-rdn. I prefer not having to add another field to the already (imo) too large LDAP form. As long as the special logic for this value is documented it should be fine imo.

Resolving patch:
diff --git a/authentik/sources/ldap/sync/membership.py b/authentik/sources/ldap/sync/membership.py
--- a/authentik/sources/ldap/sync/membership.py
+++ b/authentik/sources/ldap/sync/membership.py
@@ -39,11 +39,17 @@ def sync(self) -> int:
             if not ak_group:
                 continue
 
+            membership_mapping_attribute = LDAP_DISTINGUISHED_NAME
+            if self._source.group_membership_field == "memberUid":
+                # If memberships are based on the posixGroup's 'memberUid'
+                # attribute we use the RDN instead of the FDN to lookup members.
+                membership_mapping_attribute = LDAP_UNIQUENESS
+
             users = User.objects.filter(
-                Q(**{f"attributes__{LDAP_DISTINGUISHED_NAME}__in": members})
+                Q(**{f"attributes__{membership_mapping_attribute}__in": members})
                 | Q(
                     **{
-                        f"attributes__{LDAP_DISTINGUISHED_NAME}__isnull": True,
+                        f"attributes__{membership_mapping_attribute}__isnull": True,
                         "ak_groups__in": [ak_group],
                     }
                 )


Test patch:
diff --git a/authentik/sources/ldap/tests/mock_slapd.py b/authentik/sources/ldap/tests/mock_slapd.py
--- a/authentik/sources/ldap/tests/mock_slapd.py
+++ b/authentik/sources/ldap/tests/mock_slapd.py
@@ -77,5 +77,24 @@ def mock_slapd_connection(password: str) -> Connection:
             "objectClass": "person",
         },
     )
+    # Group with posixGroup and memberUid
+    connection.strategy.add_entry(
+        "cn=group-posix,ou=groups,dc=goauthentik,dc=io",
+        {
+            "cn": "group-posix",
+            "objectClass": "posixGroup",
+            "memberUid": ["user-posix"],
+        },
+    )
+    # User with posixAccount
+    connection.strategy.add_entry(
+        "cn=user-posix,ou=users,dc=goauthentik,dc=io",
+        {
+            "userPassword": password,
+            "uid": "user-posix",
+            "cn": "user-posix",
+            "objectClass": "posixAccount",
+        },
+    )
     connection.bind()
     return connection
diff --git a/authentik/sources/ldap/tests/test_sync.py b/authentik/sources/ldap/tests/test_sync.py
--- a/authentik/sources/ldap/tests/test_sync.py
+++ b/authentik/sources/ldap/tests/test_sync.py
@@ -137,6 +137,34 @@ def test_sync_groups_openldap(self):
             group = Group.objects.filter(name="group1")
             self.assertTrue(group.exists())
 
+    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.user_object_filter = "(objectClass=posixAccount)"
+        self.source.group_object_filter = "(objectClass=posixGroup)"
+        self.source.property_mappings.set(
+            LDAPPropertyMapping.objects.filter(
+                Q(managed__startswith="goauthentik.io/sources/ldap/default")
+                | Q(managed__startswith="goauthentik.io/sources/ldap/openldap")
+            )
+        )
+        self.source.property_mappings_group.set(
+            LDAPPropertyMapping.objects.filter(managed="goauthentik.io/sources/ldap/openldap-cn")
+        )
+        self.source.save()
+        connection = PropertyMock(return_value=mock_slapd_connection(LDAP_PASSWORD))
+        with patch("authentik.sources.ldap.models.LDAPSource.connection", connection):
+            user_sync = UserLDAPSynchronizer(self.source)
+            user_sync.sync()
+            group_sync = GroupLDAPSynchronizer(self.source)
+            group_sync.sync()
+            membership_sync = MembershipLDAPSynchronizer(self.source)
+            membership_sync.sync()
+            # Test if membership mapping based on memberUid works.
+            posix_group = Group.objects.filter(name="group-posix").first()
+            self.assertTrue(posix_group.users.filter(name="user-posix").exists())
+
     def test_tasks_ad(self):
         """Test Scheduled tasks"""
         self.source.property_mappings.set(