Skip to content

Commit 30ad472

Browse files
bhavenstBryan HavensteinClaude
authored
AAP-56394 Fix SAML and azuread authentication user lookup (ansible#872)
More comprehensive fix for AuthenticatorUser lookup. Fixes SAML and AzureAD and all authenticator types tested via manual and ATF testing --------- Co-authored-by: Bryan Havenstein <[email protected]> Co-authored-by: Claude <[email protected]>
1 parent 2ccbd39 commit 30ad472

File tree

3 files changed

+234
-32
lines changed

3 files changed

+234
-32
lines changed

ansible_base/authentication/utils/authentication.py

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import importlib
22
import logging
3-
from typing import Optional, Tuple, Union
3+
from typing import List, Optional, Tuple, Union
44

55
from django.conf import settings
66
from django.contrib.auth import get_user_model
@@ -137,16 +137,16 @@ def determine_username_from_uid_social(**kwargs) -> dict:
137137
if authenticator.setting('USERNAME_IS_FULL_EMAIL', False):
138138
selected_username = kwargs.get('details', {}).get('email', None)
139139
else:
140-
if authenticator.type == 'SAML':
141-
selected_username = kwargs.get('uid', None)
142-
else:
143-
selected_username = kwargs.get('details', {}).get('username', None)
140+
selected_username = kwargs.get('details', {}).get('username', None)
144141
if not selected_username:
145142
raise_auth_exception(
146143
_('Unable to get associated username from details, expected entry "username". Full user details: %(details)s')
147144
% {'details': kwargs.get("details", None)},
148145
backend=authenticator,
149146
)
147+
# Different authenticators use different fields for AuthenticatorUser lookup
148+
# Create a filter list
149+
uid_filter = [selected_username, kwargs.get('uid')] if kwargs.get('uid', None) else [selected_username]
150150

151151
alt_uid = authenticator.get_alternative_uid(**kwargs)
152152
email = kwargs.get('details', {}).get('email', None)
@@ -156,7 +156,7 @@ def determine_username_from_uid_social(**kwargs) -> dict:
156156
):
157157
username = migrated_username
158158
else:
159-
username = determine_username_from_uid(selected_username, email, authenticator.database_instance)
159+
username = determine_username_from_uid(uid=selected_username, uid_filter=uid_filter, email=email, authenticator=authenticator.database_instance)
160160

161161
return {"username": username}
162162

@@ -238,7 +238,9 @@ def _handle_email_fallback_strategy(uid: str, email: Union[str, list[str], None]
238238
# return new_username
239239

240240

241-
def determine_username_from_uid(uid: str = None, email: Union[str, list[str], None] = None, authenticator: Authenticator = None) -> str:
241+
def determine_username_from_uid(
242+
uid: str, uid_filter: Union[List[str], None] = None, email: Union[str, list[str], None] = None, authenticator: Authenticator = None
243+
) -> str:
242244
"""
243245
Determine what the username for the User object will be from the given uid and authenticator
244246
This will take uid like "bella" and search for an AuthenticatorUser and return:
@@ -259,7 +261,11 @@ def determine_username_from_uid(uid: str = None, email: Union[str, list[str], No
259261
raise
260262

261263
# If we have an AuthenticatorUser with the exact uid and provider than we have a match
262-
if (exact_match := AuthenticatorUser.objects.filter(uid=uid, provider=authenticator)).exists():
264+
if uid_filter:
265+
exact_match = AuthenticatorUser.objects.filter(uid__in=uid_filter, provider=authenticator)
266+
else:
267+
exact_match = AuthenticatorUser.objects.filter(uid=uid, provider=authenticator)
268+
if exact_match.exists():
263269
new_username = exact_match.first().user.username
264270
logger.info(f"Authenticator {authenticator.name} already authenticated {uid} as {new_username}")
265271
return new_username
@@ -319,7 +325,7 @@ def get_or_create_authenticator_user(
319325
if migrated_username := migrate_from_existing_authenticator(uid=uid, alt_uid=None, authenticator=authenticator, preferred_username=uid):
320326
username = migrated_username
321327
else:
322-
username = determine_username_from_uid(uid, email, authenticator)
328+
username = determine_username_from_uid(uid=uid, uid_filter=[uid], email=email, authenticator=authenticator)
323329

324330
# Step 2: Now that the username is finalized, try to find the AuthenticatorUser.
325331
auth_user = None

test_app/tests/authentication/test_merge_strategy_email_fallback.py

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,3 +179,110 @@ def test_email_fallback_complex_scenario(self, github_authenticator, ldap_authen
179179

180180
# Should also merge with first user
181181
assert ldap_username == 'complex_user'
182+
183+
184+
@pytest.mark.django_db
185+
class TestUidFilterParameter:
186+
"""Test the uid_filter parameter functionality in determine_username_from_uid."""
187+
188+
def test_uid_filter_single_matching_uid(self, existing_user_with_github_auth, github_authenticator):
189+
"""Test uid_filter with single UID that matches existing AuthenticatorUser."""
190+
# existing_user_with_github_auth has uid 'john' with GitHub authenticator
191+
192+
username = determine_username_from_uid(uid='different_uid', uid_filter=['john'], email='[email protected]', authenticator=github_authenticator)
193+
194+
# Should match existing user even though uid parameter is different
195+
assert username == 'john'
196+
197+
def test_uid_filter_multiple_uids_one_matches(self, existing_user_with_github_auth, github_authenticator):
198+
"""Test uid_filter with multiple UIDs where one matches existing AuthenticatorUser."""
199+
# existing_user_with_github_auth has uid 'john' with GitHub authenticator
200+
201+
username = determine_username_from_uid(
202+
uid='different_uid', uid_filter=['nonexistent1', 'john', 'nonexistent2'], email='[email protected]', authenticator=github_authenticator
203+
)
204+
205+
# Should match existing user with uid 'john'
206+
assert username == 'john'
207+
208+
def test_uid_filter_multiple_uids_multiple_matches(self, github_authenticator):
209+
"""Test uid_filter with multiple UIDs that all match existing AuthenticatorUsers."""
210+
# Create two users with different UIDs for same authenticator
211+
user1 = User.objects.create(username='user1', email='[email protected]')
212+
user2 = User.objects.create(username='user2', email='[email protected]')
213+
214+
AuthenticatorUser.objects.create(user=user1, uid='uid1', provider=github_authenticator, extra_data={})
215+
AuthenticatorUser.objects.create(user=user2, uid='uid2', provider=github_authenticator, extra_data={})
216+
217+
username = determine_username_from_uid(uid='different_uid', uid_filter=['uid1', 'uid2'], email='[email protected]', authenticator=github_authenticator)
218+
219+
# Should return the first match found
220+
assert username in ['user1', 'user2']
221+
222+
def test_uid_filter_no_matches(self, github_authenticator):
223+
"""Test uid_filter with UIDs that don't match any existing AuthenticatorUser."""
224+
username = determine_username_from_uid(
225+
uid='new_user', uid_filter=['nonexistent1', 'nonexistent2'], email='[email protected]', authenticator=github_authenticator
226+
)
227+
228+
# Should fall back to email fallback strategy since no exact matches
229+
assert username == 'new_user'
230+
231+
def test_uid_filter_empty_list(self, github_authenticator):
232+
"""Test uid_filter as empty list."""
233+
username = determine_username_from_uid(uid='new_user', uid_filter=[], email='[email protected]', authenticator=github_authenticator)
234+
235+
# Should fall back to default behavior (check uid parameter directly)
236+
assert username == 'new_user'
237+
238+
def test_uid_filter_none_uses_default_behavior(self, existing_user_with_github_auth, github_authenticator):
239+
"""Test uid_filter as None uses default behavior."""
240+
# existing_user_with_github_auth has uid 'john' with GitHub authenticator
241+
242+
username = determine_username_from_uid(uid='john', uid_filter=None, email='[email protected]', authenticator=github_authenticator)
243+
244+
# Should match existing user using uid parameter
245+
assert username == 'john'
246+
247+
def test_uid_filter_vs_uid_parameter_difference(self, existing_user_with_github_auth, github_authenticator):
248+
"""Test when uid parameter differs from UIDs in uid_filter."""
249+
# existing_user_with_github_auth has uid 'john' with GitHub authenticator
250+
251+
# Case 1: uid_filter matches, uid parameter doesn't
252+
username = determine_username_from_uid(uid='different_uid', uid_filter=['john'], email='[email protected]', authenticator=github_authenticator)
253+
assert username == 'john' # Should use uid_filter match
254+
255+
# Case 2: uid parameter matches, uid_filter doesn't
256+
username = determine_username_from_uid(uid='john', uid_filter=['nonexistent'], email='[email protected]', authenticator=github_authenticator)
257+
# Should fall back to email strategy since uid_filter didn't match
258+
# Uses dupe user strategy and suffixes with <hash>
259+
assert username.startswith('john')
260+
261+
def test_uid_filter_with_different_authenticator(self, existing_user_with_github_auth, ldap_authenticator):
262+
"""Test uid_filter matching UID from different authenticator."""
263+
# existing_user_with_github_auth has uid 'john' with GitHub authenticator
264+
265+
username = determine_username_from_uid(
266+
uid='different_uid',
267+
uid_filter=['john'], # This UID exists but for GitHub, not LDAP
268+
269+
authenticator=ldap_authenticator, # Different authenticator
270+
)
271+
272+
# Should not match because authenticator is different, but should merge via email
273+
assert username == 'john'
274+
275+
def test_uid_filter_cross_authenticator_no_email_merge(self, existing_user_with_github_auth, ldap_authenticator):
276+
"""Test uid_filter with different authenticator and no email merge."""
277+
# existing_user_with_github_auth has uid 'john' with GitHub authenticator
278+
279+
username = determine_username_from_uid(
280+
uid='different_uid',
281+
uid_filter=['john'], # This UID exists but for GitHub, not LDAP
282+
email='[email protected]', # Different email, won't merge
283+
authenticator=ldap_authenticator,
284+
)
285+
286+
# Should create new unique username since no match and different email
287+
assert username != 'john'
288+
assert 'different_uid' in username or username == 'different_uid'

test_app/tests/authentication/utils/test_authentication.py

Lines changed: 112 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,7 @@ def test_determine_username_from_uid(
8585
# Use different log levels based on the scenario
8686
log_level = 'warning' if related_authenticator == 'ldap' else 'info'
8787
with expected_log(self.logger, log_level, info_message):
88-
new_username = authentication.determine_username_from_uid(uid, "", local_authenticator)
88+
new_username = authentication.determine_username_from_uid(uid=uid, email="", authenticator=local_authenticator)
8989
if expected_username:
9090
assert new_username == random_user.username
9191
else:
@@ -173,7 +173,7 @@ def test_external_system_user_login(self, request, auth_fixture):
173173
uid = settings.SYSTEM_USERNAME
174174
authenticator = request.getfixturevalue(auth_fixture)
175175
with pytest.raises(AuthException):
176-
authentication.determine_username_from_uid(uid, "", authenticator)
176+
authentication.determine_username_from_uid(uid=uid, email="", authenticator=authenticator)
177177
with pytest.raises(AuthException):
178178
authentication.get_or_create_authenticator_user(uid=uid, email="", authenticator=authenticator, user_details={}, extra_data={})
179179

@@ -249,27 +249,6 @@ def test_determine_username_from_uid_social_happy_path(self, ldap_authenticator)
249249
)
250250
assert response == {'username': 'Bob'}
251251

252-
@pytest.mark.parametrize(
253-
"auth_fixture, expected_username, uid_param, details_username",
254-
[
255-
# SAML authenticator uses uid parameter (line 141)
256-
("saml_authenticator", "SamlUser", "SamlUser", "should_not_be_used"),
257-
# Non-SAML authenticators use details['username'] (line 143)
258-
("ldap_authenticator", "LdapUser", "should_not_be_used", "LdapUser"),
259-
("oidc_authenticator", "OidcUser", "should_not_be_used", "OidcUser"),
260-
("keycloak_authenticator", "KeycloakUser", "should_not_be_used", "KeycloakUser"),
261-
],
262-
)
263-
def test_determine_username_from_uid_social_authenticator_types(self, request, auth_fixture, expected_username, uid_param, details_username):
264-
"""Test username selection logic for different authenticator types (lines 140-143)"""
265-
authenticator = request.getfixturevalue(auth_fixture)
266-
response = authentication.determine_username_from_uid_social(
267-
details={'username': details_username},
268-
backend=get_authenticator_class(authenticator.type)(database_instance=authenticator),
269-
uid=uid_param,
270-
)
271-
assert response == {'username': expected_username}
272-
273252
def test_raise_auth_exception(self):
274253
try:
275254
authentication.raise_auth_exception('testing')
@@ -288,3 +267,113 @@ def test_raise_auth_exception_in_logs(self, local_authenticator, expected_log):
288267
)
289268
except AuthException:
290269
pass
270+
271+
@pytest.mark.parametrize(
272+
"uid_param, expected_uid_filter",
273+
[
274+
("user123", ["testuser", "user123"]), # When uid is provided, should include both selected_username and uid
275+
(None, ["testuser"]), # When uid is None, should only include selected_username
276+
("", ["testuser"]), # When uid is empty string, should only include selected_username
277+
],
278+
)
279+
def test_determine_username_from_uid_social_uid_filter_construction(self, ldap_authenticator, uid_param, expected_uid_filter):
280+
"""Test that uid_filter is constructed correctly in determine_username_from_uid_social."""
281+
from unittest.mock import patch
282+
283+
authenticator_class = get_authenticator_class(ldap_authenticator.type)(database_instance=ldap_authenticator)
284+
285+
# Mock migrate_from_existing_authenticator to return None (no migration)
286+
# Mock determine_username_from_uid to capture the uid_filter parameter
287+
with patch('ansible_base.authentication.utils.authentication.migrate_from_existing_authenticator') as mock_migrate:
288+
mock_migrate.return_value = None
289+
290+
with patch('ansible_base.authentication.utils.authentication.determine_username_from_uid') as mock_determine_uid:
291+
mock_determine_uid.return_value = 'testuser'
292+
293+
kwargs = {
294+
'details': {'username': 'testuser', 'email': '[email protected]'},
295+
'backend': authenticator_class,
296+
}
297+
if uid_param is not None:
298+
kwargs['uid'] = uid_param
299+
300+
response = authentication.determine_username_from_uid_social(**kwargs)
301+
302+
# Verify determine_username_from_uid was called with correct uid_filter
303+
mock_determine_uid.assert_called_once_with(
304+
uid='testuser', uid_filter=expected_uid_filter, email='[email protected]', authenticator=ldap_authenticator
305+
)
306+
assert response == {'username': 'testuser'}
307+
308+
def test_determine_username_from_uid_social_uid_filter_with_email_username(self, ldap_authenticator):
309+
"""Test uid_filter construction when USERNAME_IS_FULL_EMAIL is True."""
310+
from unittest.mock import patch
311+
312+
authenticator_class = get_authenticator_class(ldap_authenticator.type)(database_instance=ldap_authenticator)
313+
314+
# Mock migrate_from_existing_authenticator to return None (no migration)
315+
# Mock the setting method to return True for USERNAME_IS_FULL_EMAIL
316+
with patch('ansible_base.authentication.utils.authentication.migrate_from_existing_authenticator') as mock_migrate:
317+
mock_migrate.return_value = None
318+
319+
with patch.object(authenticator_class, 'setting') as mock_setting:
320+
mock_setting.return_value = True
321+
322+
with patch('ansible_base.authentication.utils.authentication.determine_username_from_uid') as mock_determine_uid:
323+
mock_determine_uid.return_value = '[email protected]'
324+
325+
response = authentication.determine_username_from_uid_social(
326+
details={'username': 'testuser', 'email': '[email protected]'}, backend=authenticator_class, uid='uid123'
327+
)
328+
329+
# When USERNAME_IS_FULL_EMAIL is True, selected_username should be the email
330+
mock_determine_uid.assert_called_once_with(
331+
uid='[email protected]', uid_filter=['[email protected]', 'uid123'], email='[email protected]', authenticator=ldap_authenticator
332+
)
333+
assert response == {'username': '[email protected]'}
334+
335+
def test_determine_username_from_uid_social_uid_filter_no_migration(self, ldap_authenticator):
336+
"""Test that uid_filter is passed to determine_username_from_uid when migration doesn't occur."""
337+
from unittest.mock import patch
338+
339+
authenticator_class = get_authenticator_class(ldap_authenticator.type)(database_instance=ldap_authenticator)
340+
341+
with patch('ansible_base.authentication.utils.authentication.migrate_from_existing_authenticator') as mock_migrate:
342+
mock_migrate.return_value = None # No migration
343+
344+
with patch('ansible_base.authentication.utils.authentication.determine_username_from_uid') as mock_determine_uid:
345+
mock_determine_uid.return_value = 'finaluser'
346+
347+
response = authentication.determine_username_from_uid_social(
348+
details={'username': 'originaluser', 'email': '[email protected]'}, backend=authenticator_class, uid='uid456'
349+
)
350+
351+
# Verify migration was attempted first
352+
mock_migrate.assert_called_once_with(uid='uid456', alt_uid=None, authenticator=ldap_authenticator, preferred_username='originaluser')
353+
354+
# Verify determine_username_from_uid was called with correct uid_filter
355+
mock_determine_uid.assert_called_once_with(
356+
uid='originaluser', uid_filter=['originaluser', 'uid456'], email='[email protected]', authenticator=ldap_authenticator
357+
)
358+
assert response == {'username': 'finaluser'}
359+
360+
def test_determine_username_from_uid_social_uid_filter_with_migration(self, ldap_authenticator):
361+
"""Test that determine_username_from_uid is not called when migration succeeds."""
362+
from unittest.mock import patch
363+
364+
authenticator_class = get_authenticator_class(ldap_authenticator.type)(database_instance=ldap_authenticator)
365+
366+
with patch('ansible_base.authentication.utils.authentication.migrate_from_existing_authenticator') as mock_migrate:
367+
mock_migrate.return_value = 'migrated_user' # Migration succeeded
368+
369+
with patch('ansible_base.authentication.utils.authentication.determine_username_from_uid') as mock_determine_uid:
370+
response = authentication.determine_username_from_uid_social(
371+
details={'username': 'originaluser', 'email': '[email protected]'}, backend=authenticator_class, uid='uid789'
372+
)
373+
374+
# Verify migration was attempted
375+
mock_migrate.assert_called_once_with(uid='uid789', alt_uid=None, authenticator=ldap_authenticator, preferred_username='originaluser')
376+
377+
# determine_username_from_uid should NOT be called when migration succeeds
378+
mock_determine_uid.assert_not_called()
379+
assert response == {'username': 'migrated_user'}

0 commit comments

Comments
 (0)