From 59e686c8b9614eb24e7276d0cddb4f0589f221d0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simonyi=20Gerg=C5=91?= <28359278+gergosimonyi@users.noreply.github.com> Date: Fri, 30 May 2025 18:34:13 +0200 Subject: [PATCH] sources/ldap: add `user_membership_attribute` (#14784) --- authentik/sources/ldap/api.py | 2 + ...10_ldapsource_user_membership_attribute.py | 32 +++++++++++++ authentik/sources/ldap/models.py | 4 ++ authentik/sources/ldap/sync/membership.py | 10 +--- authentik/sources/ldap/tests/test_sync.py | 48 ++++++++++++++++++- blueprints/schema.json | 6 +++ schema.yml | 15 ++++++ web/src/admin/sources/ldap/LDAPSourceForm.ts | 17 ++++++- 8 files changed, 123 insertions(+), 11 deletions(-) create mode 100644 authentik/sources/ldap/migrations/0010_ldapsource_user_membership_attribute.py diff --git a/authentik/sources/ldap/api.py b/authentik/sources/ldap/api.py index cc37ef77ef..b453b80552 100644 --- a/authentik/sources/ldap/api.py +++ b/authentik/sources/ldap/api.py @@ -103,6 +103,7 @@ class LDAPSourceSerializer(SourceSerializer): "user_object_filter", "group_object_filter", "group_membership_field", + "user_membership_attribute", "object_uniqueness_field", "password_login_update_internal_password", "sync_users", @@ -139,6 +140,7 @@ class LDAPSourceViewSet(UsedByMixin, ModelViewSet): "user_object_filter", "group_object_filter", "group_membership_field", + "user_membership_attribute", "object_uniqueness_field", "password_login_update_internal_password", "sync_users", diff --git a/authentik/sources/ldap/migrations/0010_ldapsource_user_membership_attribute.py b/authentik/sources/ldap/migrations/0010_ldapsource_user_membership_attribute.py new file mode 100644 index 0000000000..d498f0cf39 --- /dev/null +++ b/authentik/sources/ldap/migrations/0010_ldapsource_user_membership_attribute.py @@ -0,0 +1,32 @@ +# Generated by Django 5.1.9 on 2025-05-29 11:22 + +from django.apps.registry import Apps +from django.db import migrations, models +from django.db.backends.base.schema import BaseDatabaseSchemaEditor + + +def set_user_membership_attribute(apps: Apps, schema_editor: BaseDatabaseSchemaEditor): + LDAPSource = apps.get_model("authentik_sources_ldap", "LDAPSource") + db_alias = schema_editor.connection.alias + + LDAPSource.objects.using(db_alias).filter(group_membership_field="memberUid").all().update( + user_membership_attribute="ldap_uniq" + ) + + +class Migration(migrations.Migration): + dependencies = [ + ("authentik_sources_ldap", "0009_groupldapsourceconnection_validated_by_and_more"), + ] + + operations = [ + migrations.AddField( + model_name="ldapsource", + name="user_membership_attribute", + field=models.TextField( + default="distinguishedName", + help_text="Attribute which matches the value of `group_membership_field`.", + ), + ), + migrations.RunPython(set_user_membership_attribute, migrations.RunPython.noop), + ] diff --git a/authentik/sources/ldap/models.py b/authentik/sources/ldap/models.py index ccd34a04dd..975c019cbd 100644 --- a/authentik/sources/ldap/models.py +++ b/authentik/sources/ldap/models.py @@ -100,6 +100,10 @@ class LDAPSource(Source): default="(objectClass=person)", help_text=_("Consider Objects matching this filter to be Users."), ) + user_membership_attribute = models.TextField( + default=LDAP_DISTINGUISHED_NAME, + help_text=_("Attribute which matches the value of `group_membership_field`."), + ) group_membership_field = models.TextField( default="member", help_text=_("Field which contains members of a group.") ) diff --git a/authentik/sources/ldap/sync/membership.py b/authentik/sources/ldap/sync/membership.py index eda4738f4b..277cd90ea9 100644 --- a/authentik/sources/ldap/sync/membership.py +++ b/authentik/sources/ldap/sync/membership.py @@ -71,17 +71,11 @@ class MembershipLDAPSynchronizer(BaseLDAPSynchronizer): 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__{membership_mapping_attribute}__in": members}) + Q(**{f"attributes__{self._source.user_membership_attribute}__in": members}) | Q( **{ - f"attributes__{membership_mapping_attribute}__isnull": True, + f"attributes__{self._source.user_membership_attribute}__isnull": True, "ak_groups__in": [ak_group], } ) diff --git a/authentik/sources/ldap/tests/test_sync.py b/authentik/sources/ldap/tests/test_sync.py index a65666a710..a6b3659360 100644 --- a/authentik/sources/ldap/tests/test_sync.py +++ b/authentik/sources/ldap/tests/test_sync.py @@ -269,12 +269,56 @@ class LDAPSyncTests(TestCase): self.source.group_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.user_property_mappings.set( + [ + *LDAPSourcePropertyMapping.objects.filter( + Q(managed__startswith="goauthentik.io/sources/ldap/default") + | Q(managed__startswith="goauthentik.io/sources/ldap/openldap") + ).all(), + LDAPSourcePropertyMapping.objects.create( + name="name", + expression='return {"attributes": {"uid": list_flatten(ldap.get("uid"))}}', + ), + ] + ) + self.source.group_property_mappings.set( LDAPSourcePropertyMapping.objects.filter( - Q(managed__startswith="goauthentik.io/sources/ldap/default") - | Q(managed__startswith="goauthentik.io/sources/ldap/openldap") + managed="goauthentik.io/sources/ldap/openldap-cn" ) ) + connection = MagicMock(return_value=mock_slapd_connection(LDAP_PASSWORD)) + with patch("authentik.sources.ldap.models.LDAPSource.connection", connection): + self.source.save() + 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() + # 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_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.user_object_filter = "(objectClass=posixAccount)" + self.source.group_object_filter = "(objectClass=posixGroup)" + self.source.user_membership_attribute = "cn" + 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") + ).all(), + LDAPSourcePropertyMapping.objects.create( + name="name", + expression='return {"attributes": {"cn": list_flatten(ldap.get("cn"))}}', + ), + ] + ) self.source.group_property_mappings.set( LDAPSourcePropertyMapping.objects.filter( managed="goauthentik.io/sources/ldap/openldap-cn" diff --git a/blueprints/schema.json b/blueprints/schema.json index f4a87c9c46..a45befed4f 100644 --- a/blueprints/schema.json +++ b/blueprints/schema.json @@ -8147,6 +8147,12 @@ "title": "Group membership field", "description": "Field which contains members of a group." }, + "user_membership_attribute": { + "type": "string", + "minLength": 1, + "title": "User membership attribute", + "description": "Attribute which matches the value of `group_membership_field`." + }, "object_uniqueness_field": { "type": "string", "minLength": 1, diff --git a/schema.yml b/schema.yml index 84687dadd5..ed82b555a3 100644 --- a/schema.yml +++ b/schema.yml @@ -28581,6 +28581,10 @@ paths: name: sync_users_password schema: type: boolean + - in: query + name: user_membership_attribute + schema: + type: string - in: query name: user_object_filter schema: @@ -47893,6 +47897,9 @@ components: group_membership_field: type: string description: Field which contains members of a group. + user_membership_attribute: + type: string + description: Attribute which matches the value of `group_membership_field`. object_uniqueness_field: type: string description: Field which contains a unique Identifier. @@ -48106,6 +48113,10 @@ components: type: string minLength: 1 description: Field which contains members of a group. + user_membership_attribute: + type: string + minLength: 1 + description: Attribute which matches the value of `group_membership_field`. object_uniqueness_field: type: string minLength: 1 @@ -53443,6 +53454,10 @@ components: type: string minLength: 1 description: Field which contains members of a group. + user_membership_attribute: + type: string + minLength: 1 + description: Attribute which matches the value of `group_membership_field`. object_uniqueness_field: type: string minLength: 1 diff --git a/web/src/admin/sources/ldap/LDAPSourceForm.ts b/web/src/admin/sources/ldap/LDAPSourceForm.ts index 69d4d9d318..c0ff548aaa 100644 --- a/web/src/admin/sources/ldap/LDAPSourceForm.ts +++ b/web/src/admin/sources/ldap/LDAPSourceForm.ts @@ -429,10 +429,25 @@ 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,...'. When selecting 'Lookup using a user attribute', this should be a user attribute, otherwise a group attribute.", + "Field which contains members of a group. The value of this field is matched against User membership attribute.", )}

+ + +

+ ${msg("Attribute which matches the value of Group membership field.")} +

+