sources/ldap: lookup group memberships from user attribute (#12661)
* 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 <tanamarieberry@yahoo.com> Signed-off-by: Amélie Krejčí <amelie@krejci.vip> * 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 <jens@goauthentik.io> * re-migrate Signed-off-by: Jens Langhammer <jens@goauthentik.io> * revert website changes in favor of #13966 Signed-off-by: Marc 'risson' Schmitt <marc.schmitt@risson.space> * update frontend help text Signed-off-by: Marc 'risson' Schmitt <marc.schmitt@risson.space> --------- Signed-off-by: Amélie Krejčí <amelie@krejci.vip> Signed-off-by: Jens Langhammer <jens@goauthentik.io> Signed-off-by: Marc 'risson' Schmitt <marc.schmitt@risson.space> Co-authored-by: Shawn Weeks <sweeks@weeksconsulting.us> Co-authored-by: Tana M Berry <tanamarieberry@yahoo.com> Co-authored-by: Jo Rhett <geek@jorhett.com> Co-authored-by: Jens Langhammer <jens@goauthentik.io> Co-authored-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>
This commit is contained in:

committed by
GitHub

parent
f1ac4ff9c9
commit
5d2685341d
@ -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(
|
||||
|
@ -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": []
|
||||
|
19
schema.yml
19
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
|
||||
|
@ -412,7 +412,29 @@ export class LDAPSourceForm extends BaseSourceForm<LDAPSource> {
|
||||
/>
|
||||
<p class="pf-c-form__helper-text">
|
||||
${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.",
|
||||
)}
|
||||
</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>
|
||||
|
Reference in New Issue
Block a user