Skip to content

Commit 20dc761

Browse files
[AAP-51575] Fix AttributeError in _lowercase_attr_triggers for list values (#802)
- Fixed _lowercase_attr_triggers to handle both string and list values - Added proper case-folding for individual elements in lists - Added comprehensive test cases for 'in' operator with list values - Ensures 'in' operator only accepts arrays as per trigger definition - Added validation to warn about invalid 'in' operator usage with strings
1 parent 59aaf99 commit 20dc761

File tree

2 files changed

+326
-44
lines changed

2 files changed

+326
-44
lines changed

ansible_base/authentication/utils/claims.py

Lines changed: 145 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import logging
44
import re
55
from enum import Enum, auto
6-
from typing import List, Optional, Union
6+
from typing import Any, List, Optional, Union
77
from uuid import uuid4
88

99
from django.conf import settings
@@ -54,13 +54,14 @@ def create_claims(authenticator: Authenticator, username: str, attrs: dict, grou
5454
rule_responses = []
5555
# Assume we will have access
5656
access_allowed = True
57-
logger.info(f"Creating mapping for user {username} through authenticator {authenticator.name}")
58-
logger.debug(f"{username}'s groups: {groups}")
59-
logger.debug(f"{username}'s attrs: {attrs}")
6057

6158
# debug tracking ID
6259
tracking_id = str(uuid4())
6360

61+
logger.info(f"[{tracking_id}] Creating mapping for user {username} through authenticator {authenticator.name}")
62+
logger.debug(f"[{tracking_id}] {username}'s groups: {groups}")
63+
logger.debug(f"[{tracking_id}] {username}'s attrs: {attrs}")
64+
6465
# load the maps
6566
maps = AuthenticatorMap.objects.filter(authenticator=authenticator.pk).order_by("order")
6667
logger.debug(f"Processing {maps.count()} map(s) for Authenticator ID [{authenticator.pk}] ID [{tracking_id}]")
@@ -240,7 +241,7 @@ def process_groups(trigger_condition: dict, groups: list, map_id: int, tracking_
240241

241242
invalid_conditions = set(trigger_condition.keys()) - set(TRIGGER_DEFINITION['groups']['keys'].keys())
242243
if invalid_conditions:
243-
logger.warning(f"The conditions {', '.join(invalid_conditions)} for groups in mapping {map_id} are invalid and won't be processed")
244+
logger.warning(f"[{tracking_id}] The conditions {', '.join(invalid_conditions)} for groups in mapping {map_id} are invalid and won't be processed")
244245

245246
set_of_user_groups = set(groups)
246247

@@ -283,6 +284,45 @@ def has_access_with_join(current_access: Optional[bool], new_access: bool, condi
283284
return current_access and new_access
284285

285286

287+
def _lowercase_value(value: Any) -> Any:
288+
"""
289+
Convert a value to lowercase, handling different types appropriately.
290+
291+
Args:
292+
value: The value to convert (str, list, or other)
293+
294+
Returns:
295+
The converted value with appropriate case folding applied
296+
"""
297+
if isinstance(value, str):
298+
return value.casefold()
299+
elif isinstance(value, list):
300+
# Handle list values (for "in" operator which should only accept arrays)
301+
return [str(item).casefold() for item in value]
302+
else:
303+
# Keep other types as-is
304+
return value
305+
306+
307+
def _lowercase_dict(condition: dict) -> dict:
308+
"""
309+
Convert all values in a condition dictionary to lowercase.
310+
311+
Args:
312+
condition: Dictionary of
313+
314+
Returns:
315+
New dictionary with case-folded values (keys will remain the same)
316+
"""
317+
if not condition: # empty dict
318+
return {}
319+
320+
updated_condition = {}
321+
for key, value in condition.items():
322+
updated_condition[key] = _lowercase_value(value)
323+
return updated_condition
324+
325+
286326
def _lowercase_attr_triggers(trigger_condition: dict) -> dict:
287327
"""
288328
Lower case all keys (attribute names) and contained attribute values
@@ -292,61 +332,137 @@ def _lowercase_attr_triggers(trigger_condition: dict) -> dict:
292332
if isinstance(condition, str):
293333
updated_condition = condition.casefold()
294334
elif isinstance(condition, dict):
295-
if not condition: # empty dict
296-
updated_condition = {}
297-
for operator, value in condition.items():
298-
updated_condition = {operator: value.casefold()}
335+
updated_condition = _lowercase_dict(condition)
299336
else:
300337
updated_condition = condition
301338

302-
ci_trigger_condition[attr.casefold()] = updated_condition # join_condition
339+
ci_trigger_condition[attr.casefold()] = updated_condition
303340
return ci_trigger_condition
304341

305342

306-
def process_user_attributes(trigger_condition: dict, attributes: dict, map_id: int, tracking_id: str) -> TriggerResult:
343+
def _validate_join_condition(join_condition, map_id: int, tracking_id: str) -> str:
307344
"""
308-
Looks at a maps trigger for an attribute and the users attributes and determines if the trigger is defined for this user.
309-
Attribute names are compared case-insensitively when FEATURE_CASE_INSENSITIVE_AUTH_MAPS is enabled.
345+
Validate and normalize the join condition, defaulting to 'or' if invalid.
346+
347+
Args:
348+
join_condition: The join condition to validate
349+
map_id: Authenticator map ID for logging
350+
tracking_id: Tracking ID for logging
351+
352+
Returns:
353+
Valid join condition ('or' or 'and')
354+
"""
355+
if join_condition not in TRIGGER_DEFINITION['attributes']['keys']['join_condition']['choices']:
356+
logger.warning(f"[{tracking_id}] Trigger join_condition {join_condition} on authenticator map {map_id} is invalid and will be set to 'or'")
357+
return 'or'
358+
return join_condition
359+
360+
361+
def _validate_attribute_conditions(attribute: str, condition: dict, map_id: int, tracking_id: str) -> bool:
362+
"""
363+
Validate attribute conditions and log warnings for invalid ones.
364+
365+
Args:
366+
attribute: The attribute name
367+
condition: The condition dictionary for this attribute
368+
map_id: Authenticator map ID for logging
369+
tracking_id: Tracking ID for logging
370+
371+
Returns:
372+
True if conditions are valid and should be processed, False if should be skipped
373+
"""
374+
# Warn if there are any invalid conditions, we are just going to ignore them
375+
invalid_conditions = set(condition.keys()) - set(TRIGGER_DEFINITION['attributes']['keys']['*']['keys'].keys())
376+
if invalid_conditions:
377+
logger.warning(
378+
f"[{tracking_id}] The conditions {', '.join(invalid_conditions)} for attribute {attribute} "
379+
f"in authenticator map {map_id} are invalid and won't be processed"
380+
)
381+
382+
# Validate that 'in' operator only accepts arrays
383+
if "in" in condition and not isinstance(condition["in"], list):
384+
logger.warning(
385+
f"[{tracking_id}] The 'in' operator for attribute {attribute} in authenticator map {map_id} "
386+
f"must use an array, not {type(condition['in']).__name__}. This condition will be ignored."
387+
)
388+
return False
389+
390+
return True
391+
392+
393+
def _prepare_case_insensitive_data(trigger_condition: dict, attributes: dict, map_id: int, tracking_id: str) -> tuple[dict, dict]:
394+
"""
395+
Prepare trigger conditions and attributes for case-insensitive comparison if enabled.
396+
397+
Args:
398+
trigger_condition: Original trigger conditions
399+
attributes: Original user attributes
400+
map_id: Authenticator map ID for logging
401+
tracking_id: Tracking ID for logging
402+
403+
Returns:
404+
Tuple of (processed_trigger_condition, processed_attributes)
310405
"""
311406
if _is_case_insensitivity_enabled():
312407
_prefixed_debug(map_id, tracking_id, f"[{tracking_id}] Case insensitivity enabled, converting attributes and values to lowercase")
313408
attributes = {f"{k}".casefold(): v for k, v in attributes.items()}
314409
trigger_condition = _lowercase_attr_triggers(trigger_condition)
315410

411+
return trigger_condition, attributes
412+
413+
414+
def _normalize_user_value(user_value):
415+
"""
416+
Normalize user value to a list format for consistent processing.
417+
418+
Args:
419+
user_value: The user attribute value
420+
421+
Returns:
422+
List containing the user value(s)
423+
"""
424+
if type(user_value) is not list:
425+
# If the value is a string then convert it to a list
426+
return [user_value]
427+
return user_value
428+
429+
430+
def process_user_attributes(trigger_condition: dict, attributes: dict, map_id: int, tracking_id: str) -> TriggerResult:
431+
"""
432+
Looks at a maps trigger for an attribute and the users attributes and determines if the trigger is defined for this user.
433+
Attribute names are compared case-insensitively when FEATURE_CASE_INSENSITIVE_AUTH_MAPS is enabled.
434+
"""
435+
# Prepare data for case-insensitive comparison if needed
436+
trigger_condition, attributes = _prepare_case_insensitive_data(trigger_condition, attributes, map_id, tracking_id)
437+
438+
# Extract and validate join condition
316439
has_access = None
317440
join_condition = trigger_condition.pop('join_condition', 'or')
318-
if join_condition not in TRIGGER_DEFINITION['attributes']['keys']['join_condition']['choices']:
319-
logger.warning(f"[{tracking_id}] Trigger join_condition {join_condition} on authenticator map {map_id} is invalid and will be set to 'or'")
320-
join_condition = 'or'
441+
join_condition = _validate_join_condition(join_condition, map_id, tracking_id)
321442

443+
# Process each attribute in the trigger condition
322444
for attribute in trigger_condition.keys():
323445
# If we have already determined the result, we can break out and return
324446
if _check_early_exit(has_access, join_condition, map_id, tracking_id):
325447
break
326448

327-
# Warn if there are any invalid conditions, we are just going to ignore them
328-
invalid_conditions = set(trigger_condition[attribute].keys()) - set(TRIGGER_DEFINITION['attributes']['keys']['*']['keys'].keys())
329-
if invalid_conditions:
330-
logger.warning(
331-
f"[{tracking_id}] The conditions {', '.join(invalid_conditions)} for attribute {attribute} "
332-
f"in authenticator map {map_id} are invalid and won't be processed"
333-
)
449+
# Validate attribute conditions
450+
if not _validate_attribute_conditions(attribute, trigger_condition[attribute], map_id, tracking_id):
451+
continue
334452

335453
# The attribute is an empty dict we just need to see if the user has the attribute or not
336454
if trigger_condition[attribute] == {}:
337455
has_access = has_access_with_join(has_access, _check_empty_attribute(attribute, attributes, map_id, tracking_id), join_condition)
338456
continue
339457

458+
# Check if user has the attribute
340459
user_value = attributes.get(attribute, None)
341-
# If the user does not contain the attribute then we can't check any further, don't set has_access and just continue
342460
if user_value is None:
343461
_prefixed_debug(map_id, tracking_id, f"Attr [{attribute}] is not present in user attributes, skipping")
344462
continue
345463

346-
if type(user_value) is not list:
347-
# If the value is a string then convert it to a list
348-
user_value = [user_value]
349-
464+
# Normalize user value and process
465+
user_value = _normalize_user_value(user_value)
350466
has_access = _process_user_value(has_access, trigger_condition, user_value, join_condition, attribute, map_id, tracking_id)
351467

352468
return TriggerResult.ALLOW if has_access else TriggerResult.SKIP
@@ -391,7 +507,7 @@ def _evaluate_ends_with(user_value: str, trigger_value: str) -> bool:
391507
return user_value.endswith(trigger_value)
392508

393509

394-
def _evaluate_in(user_value: str, trigger_value: str) -> bool:
510+
def _evaluate_in(user_value: str, trigger_value: list) -> bool:
395511
"""Check if user value is in trigger value list."""
396512
return user_value in trigger_value
397513

0 commit comments

Comments
 (0)