Compare commits

...

13 Commits

Author SHA1 Message Date
67b1ae7add re-migrate
Signed-off-by: Jens Langhammer <jens@goauthentik.io>
2025-03-26 18:06:52 +01:00
9fd40a0a3d format
Signed-off-by: Jens Langhammer <jens@goauthentik.io>
2025-03-26 17:18:40 +01:00
cdfd92c49e sources/ldap: implement test 2025-03-24 20:49:29 +01:00
f6a3105fa5 Add a test for memberof attribute 2025-03-24 20:49:29 +01:00
49067f8cdc sources/ldap: add missing spaces in docstrings
Follows suggestions from @jorhett
2025-03-24 20:49:29 +01:00
badd76998a website/docs: simplify wording of attribute documentation
Follows suggestions from @jorhett
2025-03-24 20:49:29 +01:00
17d93dcb38 Update website/docs/users-sources/sources/directory-sync/active-directory/index.md
Co-authored-by: Tana M Berry <tanamarieberry@yahoo.com>
Signed-off-by: Amélie Krejčí <amelie@krejci.vip>
2025-03-24 20:49:29 +01:00
ade22c810f website/docs: add note about lookups to AD docs 2025-03-24 20:49:29 +01:00
383f9ecdb0 sources/ldap: lint changed files 2025-03-24 20:49:29 +01:00
9471b1d9df sources/ldap: add group membership toggle ui element 2025-03-24 20:49:28 +01:00
4119f93b54 sources/ldap: add schema changes 2025-03-24 20:49:28 +01:00
9dfa792757 sources/ldap: implement working membership lookups 2025-03-24 20:49:28 +01:00
269a557c58 sources/ldap: add support for group lookups from user 2025-03-24 20:49:27 +01:00
10 changed files with 164 additions and 9 deletions

View File

@ -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"]

View File

@ -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",
),
),
]

View File

@ -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"

View File

@ -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)

View File

@ -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",

View File

@ -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(

View File

@ -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": []

View File

@ -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

View File

@ -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}

View File

@ -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.