Compare commits
	
		
			13 Commits
		
	
	
		
			website/do
			...
			feature/so
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 67b1ae7add | |||
| 9fd40a0a3d | |||
| cdfd92c49e | |||
| f6a3105fa5 | |||
| 49067f8cdc | |||
| badd76998a | |||
| 17d93dcb38 | |||
| ade22c810f | |||
| 383f9ecdb0 | |||
| 9471b1d9df | |||
| 4119f93b54 | |||
| 9dfa792757 | |||
| 269a557c58 | 
@ -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"]
 | 
			
		||||
 | 
			
		||||
@ -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",
 | 
			
		||||
            ),
 | 
			
		||||
        ),
 | 
			
		||||
    ]
 | 
			
		||||
@ -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"
 | 
			
		||||
 | 
			
		||||
@ -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)
 | 
			
		||||
 | 
			
		||||
@ -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",
 | 
			
		||||
 | 
			
		||||
@ -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(
 | 
			
		||||
 | 
			
		||||
@ -7887,6 +7887,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": []
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										19
									
								
								schema.yml
									
									
									
									
									
								
							
							
						
						
									
										19
									
								
								schema.yml
									
									
									
									
									
								
							@ -27348,6 +27348,10 @@ paths:
 | 
			
		||||
            format: uuid
 | 
			
		||||
        explode: true
 | 
			
		||||
        style: form
 | 
			
		||||
      - in: query
 | 
			
		||||
        name: lookup_groups_from_user
 | 
			
		||||
        schema:
 | 
			
		||||
          type: boolean
 | 
			
		||||
      - in: query
 | 
			
		||||
        name: name
 | 
			
		||||
        schema:
 | 
			
		||||
@ -45967,6 +45971,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
 | 
			
		||||
@ -46163,6 +46172,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
 | 
			
		||||
@ -51261,6 +51275,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
 | 
			
		||||
 | 
			
		||||
@ -416,6 +416,28 @@ export class LDAPSourceForm extends BaseSourceForm<LDAPSource> {
 | 
			
		||||
                            )}
 | 
			
		||||
                        </p>
 | 
			
		||||
                    </ak-form-element-horizontal>
 | 
			
		||||
                    <ak-form-element-horizontal name="lookupGroupsFromUser">
 | 
			
		||||
                        <label class="pf-c-switch">
 | 
			
		||||
                            <input
 | 
			
		||||
                                class="pf-c-switch__input"
 | 
			
		||||
                                type="checkbox"
 | 
			
		||||
                                ?checked=${first(this.instance?.lookupGroupsFromUser, false)}
 | 
			
		||||
                            />
 | 
			
		||||
                            <span class="pf-c-switch__toggle">
 | 
			
		||||
                                <span class="pf-c-switch__toggle-icon">
 | 
			
		||||
                                    <i class="fas fa-check" aria-hidden="true"></i>
 | 
			
		||||
                                </span>
 | 
			
		||||
                            </span>
 | 
			
		||||
                            <span class="pf-c-switch__label"
 | 
			
		||||
                                >${msg("Lookup using user attribute")}</span
 | 
			
		||||
                            >
 | 
			
		||||
                        </label>
 | 
			
		||||
                        <p class="pf-c-form__helper-text">
 | 
			
		||||
                            ${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:'.",
 | 
			
		||||
                            )}
 | 
			
		||||
                        </p>
 | 
			
		||||
                    </ak-form-element-horizontal>
 | 
			
		||||
                    <ak-form-element-horizontal
 | 
			
		||||
                        label=${msg("Object uniqueness field")}
 | 
			
		||||
                        ?required=${true}
 | 
			
		||||
 | 
			
		||||
@ -61,6 +61,7 @@ Additional settings that might need to be adjusted based on the setup of your do
 | 
			
		||||
- User object filter: Which objects should be considered users. For Active Directory set it to `(&(objectClass=user)(!(objectClass=computer)))` to exclude Computer accounts.
 | 
			
		||||
- Group object filter: Which objects should be considered groups.
 | 
			
		||||
- Group membership field: Which user field saves the group membership
 | 
			
		||||
- Look up using a user attribute: Acquire group membership from a User object attribute (`memberOf`) instead of a Group attribute (`member`). This works with directories with nested groups memberships (Active Directory, RedHat IDM/FreeIPA), using `memberOf:1.2.840.113556.1.4.1941:` as the group membership field.
 | 
			
		||||
- Object uniqueness field: A user field which contains a unique Identifier
 | 
			
		||||
 | 
			
		||||
After you save the source, a synchronization will start in the background. When its done, you can see the summary under Dashboards -> System Tasks.
 | 
			
		||||
 | 
			
		||||
		Reference in New Issue
	
	Block a user