Skip to content

Commit 79c4f9e

Browse files
authored
[AAP-45540] Add PosixUIDGroupType to LDAP Group Type list (#748)
## Description <!-- Mandatory: Provide a clear, concise description of the changes and their purpose --> LDAP Plugin is currently missing `PosixUIDGroupType` in the Group Type list. This PR adds this group type similar to how awx has implemented. ## Type of Change <!-- Mandatory: Check one or more boxes that apply --> - [ ] Bug fix (non-breaking change which fixes an issue) - [x] New feature (non-breaking change which adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) - [ ] Documentation update - [ ] Test update - [ ] Refactoring (no functional changes) - [ ] Development environment change - [ ] Configuration change ## Self-Review Checklist <!-- These items help ensure quality - they complement our automated CI checks --> - [x] I have performed a self-review of my code - [x] I have added relevant comments to complex code sections - [ ] I have updated documentation where needed - [x] I have considered the security impact of these changes - [x] I have considered performance implications - [x] I have thought about error handling and edge cases - [x] I have tested the changes in my local environment ## Testing Instructions <!-- Optional for test-only changes. Mandatory for all other changes --> <!-- Must be detailed enough for reviewers to reproduce --> ### Prerequisites <!-- List any specific setup required --> ### Steps to Test 1. 2. 3. ### Expected Results <!-- Describe what should happen after following the steps --> ## Additional Context <!-- Optional but helpful information --> ### Required Actions <!-- Check if changes require work in other areas --> <!-- Remove section if no external actions needed --> - [ ] Requires documentation updates <!-- API docs, feature docs, deployment guides --> - [ ] Requires downstream repository changes <!-- Specify repos: django-ansible-base, eda-server, etc. --> - [ ] Requires infrastructure/deployment changes <!-- CI/CD, installer updates, new services --> - [ ] Requires coordination with other teams <!-- UI team, platform services, infrastructure --> - [ ] Blocked by PR/MR: #XXX <!-- Reference blocking PRs/MRs with brief context --> ### Screenshots/Logs <!-- Add if relevant to demonstrate the changes --> <img width="400" alt="Screenshot 2025-06-30 at 11 15 49 AM" src="https://github.com/user-attachments/assets/2350d2fa-9571-4f6e-86df-ab8c6a5c3875" /> <img width="843" alt="Screenshot 2025-06-30 at 11 15 49 AM" src="https://github.com/user-attachments/assets/5730af77-2d9a-4b7d-a423-81180faf4164" />
1 parent 9289e30 commit 79c4f9e

File tree

2 files changed

+162
-2
lines changed
  • ansible_base/authentication/authenticator_plugins
  • test_app/tests/authentication/authenticator_plugins

2 files changed

+162
-2
lines changed

ansible_base/authentication/authenticator_plugins/ldap.py

Lines changed: 74 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from typing import Any
66

77
import ldap
8+
from django.utils.encoding import force_str
89
from django.utils.translation import gettext_lazy as _
910
from django_auth_ldap import config
1011
from django_auth_ldap.backend import LDAPBackend
@@ -28,6 +29,68 @@
2829
user_search_string = '%(user)s'
2930

3031

32+
class PosixUIDGroupType(LDAPGroupType):
33+
"""
34+
An LDAPGroupType subclass that handles non-standard Directory Servers.
35+
"""
36+
37+
def __init__(self, name_attr='cn', ldap_group_user_attr='uid'):
38+
self.ldap_group_user_attr = ldap_group_user_attr
39+
super().__init__(name_attr)
40+
41+
def user_groups(self, ldap_user, group_search):
42+
"""
43+
Searches for any group that is either the user's primary or contains the
44+
user as a member.
45+
"""
46+
groups = []
47+
48+
try:
49+
user_uid = ldap_user.attrs[self.ldap_group_user_attr][0]
50+
51+
if 'gidNumber' in ldap_user.attrs:
52+
user_gid = ldap_user.attrs['gidNumber'][0]
53+
filterstr = "(|(gidNumber={})(memberUid={}))".format(
54+
self.ldap.filter.escape_filter_chars(user_gid),
55+
self.ldap.filter.escape_filter_chars(user_uid),
56+
)
57+
else:
58+
filterstr = "(memberUid={})".format(self.ldap.filter.escape_filter_chars(user_uid))
59+
60+
search = group_search.search_with_additional_term_string(filterstr)
61+
search.attrlist = [str(self.name_attr)]
62+
groups = search.execute(ldap_user.connection)
63+
except (KeyError, IndexError):
64+
pass
65+
66+
return groups
67+
68+
def is_member(self, ldap_user, group_dn):
69+
"""
70+
Returns True if the group is the user's primary group or if the user is
71+
listed in the group's memberUid attribute.
72+
"""
73+
is_member = False
74+
try:
75+
user_uid = ldap_user.attrs[self.ldap_group_user_attr][0]
76+
77+
try:
78+
is_member = ldap_user.connection.compare_s(force_str(group_dn), 'memberUid', force_str(user_uid))
79+
except (ldap.UNDEFINED_TYPE, ldap.NO_SUCH_ATTRIBUTE):
80+
is_member = False
81+
82+
if not is_member:
83+
try:
84+
user_gid = ldap_user.attrs['gidNumber'][0]
85+
is_member = ldap_user.connection.compare_s(force_str(group_dn), 'gidNumber', force_str(user_gid))
86+
except (ldap.UNDEFINED_TYPE, ldap.NO_SUCH_ATTRIBUTE):
87+
is_member = False
88+
except (KeyError, IndexError):
89+
is_member = False
90+
91+
return is_member
92+
93+
3194
def validate_ldap_dn(value: str, with_user: bool = False, required: bool = True) -> None:
3295
if not value and not required:
3396
return
@@ -333,7 +396,7 @@ def validate(self, attrs):
333396
# Check interdependent fields
334397
errors = {}
335398

336-
group_type_class = getattr(config, attrs['GROUP_TYPE'], None)
399+
group_type_class = find_class_in_modules(attrs["GROUP_TYPE"])
337400
if group_type_class:
338401
group_type_params = attrs['GROUP_TYPE_PARAMS']
339402
logger.error(f"Validating group type params for {attrs['GROUP_TYPE']}")
@@ -400,7 +463,7 @@ def __init__(self, prefix: str = 'AUTH_LDAP_', defaults: dict = {}):
400463
setattr(self, 'CONNECTION_OPTIONS', internal_data)
401464

402465
# Group type needs to be an object instead of a String so instantiate it
403-
group_type_class = getattr(config, defaults['GROUP_TYPE'], None)
466+
group_type_class = find_class_in_modules(defaults["GROUP_TYPE"])
404467
setattr(self, 'GROUP_TYPE', group_type_class(**defaults['GROUP_TYPE_PARAMS']))
405468

406469

@@ -528,3 +591,12 @@ def get_or_build_user(self, username, ldap_user):
528591
)
529592

530593
return user, created
594+
595+
596+
def find_class_in_modules(class_name: str) -> object:
597+
"""
598+
Used to find ldap subclasses by string
599+
"""
600+
if class_name == "PosixUIDGroupType":
601+
return PosixUIDGroupType
602+
return getattr(config, class_name, None)

test_app/tests/authentication/authenticator_plugins/test_ldap.py

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
import ldap
66
import pytest
7+
from django_auth_ldap import config
78
from rest_framework.serializers import ValidationError
89
from typeguard import suppress_type_checks
910

@@ -12,6 +13,8 @@
1213
AuthenticatorPlugin,
1314
LDAPSearchField,
1415
LDAPSettings,
16+
PosixUIDGroupType,
17+
find_class_in_modules,
1518
validate_ldap_filter,
1619
)
1720
from ansible_base.authentication.models import Authenticator
@@ -683,3 +686,88 @@ def test_ldap_user_search_validation(
683686
)
684687
def test_ldap_search_field_is_single_search(value, expected_result):
685688
assert LDAPSearchField.is_single_search(value) is expected_result
689+
690+
691+
@pytest.mark.parametrize(
692+
"cls_name,cls",
693+
[("PosixGroupType", config.PosixGroupType), ("PosixUIDGroupType", PosixUIDGroupType), ("NonExistentClass", None)],
694+
)
695+
def test_find_class_in_modules(cls_name, cls):
696+
found_cls = find_class_in_modules(cls_name)
697+
if found_cls:
698+
assert found_cls.__name__ == cls.__name__
699+
else:
700+
assert found_cls is cls
701+
702+
703+
@pytest.fixture
704+
def group_type():
705+
return PosixUIDGroupType()
706+
707+
708+
@pytest.fixture
709+
def ldap_user():
710+
user = MagicMock()
711+
user.connection = MagicMock()
712+
return user
713+
714+
715+
@pytest.fixture
716+
def group_search():
717+
return MagicMock()
718+
719+
720+
def test_user_groups_with_gidNumber(group_type, ldap_user, group_search):
721+
ldap_user.attrs = {"uid": ["jdoe"], "gidNumber": ["1000"]}
722+
mock_search = MagicMock()
723+
mock_search.execute.return_value = ["group1", "group2"]
724+
group_search.search_with_additional_term_string.return_value = mock_search
725+
groups = group_type.user_groups(ldap_user, group_search)
726+
assert groups == ["group1", "group2"]
727+
group_search.search_with_additional_term_string.assert_called_once()
728+
mock_search.execute.assert_called_once()
729+
730+
731+
def test_user_groups_without_gidNumber(group_type, ldap_user, group_search):
732+
ldap_user.attrs = {"uid": ["jdoe"]}
733+
mock_search = MagicMock()
734+
mock_search.execute.return_value = ["group3"]
735+
group_search.search_with_additional_term_string.return_value = mock_search
736+
groups = group_type.user_groups(ldap_user, group_search)
737+
assert groups == ["group3"]
738+
739+
740+
def test_user_groups_missing_uid(group_type, ldap_user, group_search):
741+
ldap_user.attrs = {"gidNumber": ["1000"]}
742+
groups = group_type.user_groups(ldap_user, group_search)
743+
assert groups == []
744+
745+
746+
def test_is_member_by_memberUid(group_type, ldap_user):
747+
ldap_user.attrs = {"uid": ["jdoe"], "gidNumber": ["1000"]}
748+
ldap_user.connection.compare_s.side_effect = [True, False]
749+
result = group_type.is_member(ldap_user, "cn=group1,dc=example,dc=com")
750+
assert result is True
751+
assert ldap_user.connection.compare_s.call_count == 1
752+
753+
754+
def test_is_member_by_gidNumber(group_type, ldap_user):
755+
ldap_user.attrs = {"uid": ["jdoe"], "gidNumber": ["1000"]}
756+
# Simulate memberUid fails, gidNumber succeeds
757+
ldap_user.connection.compare_s.side_effect = [False, True]
758+
result = group_type.is_member(ldap_user, "cn=group2,dc=example,dc=com")
759+
assert result is True
760+
assert ldap_user.connection.compare_s.call_count == 2
761+
762+
763+
def test_is_member_none_match(group_type, ldap_user):
764+
ldap_user.attrs = {"uid": ["jdoe"], "gidNumber": ["1000"]}
765+
ldap_user.connection.compare_s.side_effect = [False, False]
766+
result = group_type.is_member(ldap_user, "cn=group3,dc=example,dc=com")
767+
assert result is False
768+
769+
770+
def test_is_member_missing_uid(group_type, ldap_user):
771+
ldap_user.attrs = {"gidNumber": ["1000"]}
772+
result = group_type.is_member(ldap_user, "cn=group,dc=example,dc=com")
773+
assert result is False

0 commit comments

Comments
 (0)