Coverage for src/local_deep_research/security/security_settings.py: 95%

55 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-22 21:20 +0000

1"""Security settings utilities. 

2 

3Provides functions to load security settings from JSON defaults with 

4environment variable overrides. Used during app initialization before 

5the full settings system is available. 

6""" 

7 

8import json 

9from functools import lru_cache 

10from pathlib import Path 

11from typing import Any, Optional, TypeVar, Union 

12 

13from loguru import logger 

14 

15from local_deep_research.utilities.type_utils import unwrap_setting 

16from local_deep_research.settings.manager import check_env_setting 

17 

18T = TypeVar("T", int, float, str, bool) 

19 

20# Path to security settings JSON 

21_SETTINGS_PATH = ( 

22 Path(__file__).parent.parent / "defaults" / "settings_security.json" 

23) 

24 

25 

26@lru_cache(maxsize=1) 

27def _load_security_settings() -> dict: 

28 """Load and cache security settings from JSON file. 

29 

30 Returns: 

31 Dictionary of security settings, or empty dict on error. 

32 """ 

33 try: 

34 if _SETTINGS_PATH.exists(): 

35 # nosemgrep: semgrep.rules.path-traversal-risk, reason: Fixed settings filename below the installed package __file__ directory 

36 with open(_SETTINGS_PATH, "r", encoding="utf-8-sig") as f: 

37 return json.load(f) 

38 except Exception: 

39 logger.warning( 

40 f"Failed to load security settings from {_SETTINGS_PATH}" 

41 ) 

42 return {} 

43 

44 

45def _convert_value(value: str, target_type: type, key: str) -> Optional[Any]: 

46 """Convert string value to target type with proper error handling. 

47 

48 Args: 

49 value: String value to convert 

50 target_type: Target type (int, float, str, bool) 

51 key: Setting key for error messages 

52 

53 Returns: 

54 Converted value or None if conversion fails 

55 """ 

56 try: 

57 if target_type is bool: 

58 # Handle boolean strings properly 

59 return value.lower() in ("true", "1", "yes", "on") 

60 if target_type is int: 

61 return int(value) 

62 if target_type is float: 

63 return float(value) 

64 return str(value) 

65 except (ValueError, TypeError): 

66 logger.warning( 

67 f"Invalid value for {key}: '{value}' cannot be converted to {target_type.__name__}" 

68 ) 

69 return None 

70 

71 

72def _validate_bounds( 

73 value: Union[int, float], 

74 min_value: Optional[Union[int, float]], 

75 max_value: Optional[Union[int, float]], 

76 key: str, 

77) -> Union[int, float]: 

78 """Validate that value is within min/max bounds. 

79 

80 Args: 

81 value: Value to validate 

82 min_value: Minimum allowed value (inclusive) 

83 max_value: Maximum allowed value (inclusive) 

84 key: Setting key for error messages 

85 

86 Returns: 

87 Clamped value within bounds 

88 """ 

89 original = value 

90 

91 if min_value is not None and value < min_value: 

92 value = min_value 

93 logger.warning( 

94 f"Value {original} for {key} is below minimum {min_value}, using {value}" 

95 ) 

96 

97 if max_value is not None and value > max_value: 

98 value = max_value 

99 logger.warning( 

100 f"Value {original} for {key} is above maximum {max_value}, using {value}" 

101 ) 

102 

103 return value 

104 

105 

106def get_security_default(key: str, default: T) -> T: 

107 """Load a security setting with environment variable override. 

108 

109 Priority order: 

110 1. Environment variable (LDR_SECURITY_<KEY>) 

111 2. JSON defaults file value 

112 3. Provided default 

113 

114 Environment variables are validated against min/max bounds defined 

115 in the JSON settings file. 

116 

117 Args: 

118 key: Setting key (e.g., "security.session_remember_me_days") 

119 default: Default value if setting not found (also determines type) 

120 

121 Returns: 

122 Setting value of same type as default 

123 

124 Example: 

125 >>> get_security_default("security.session_remember_me_days", 30) 

126 30 # or value from env/JSON 

127 """ 

128 settings = _load_security_settings() 

129 setting_data = settings.get(key, {}) 

130 

131 # Get min/max bounds from settings schema 

132 min_value = ( 

133 setting_data.get("min_value") 

134 if isinstance(setting_data, dict) 

135 else None 

136 ) 

137 max_value = ( 

138 setting_data.get("max_value") 

139 if isinstance(setting_data, dict) 

140 else None 

141 ) 

142 

143 # Check environment variable first (e.g., LDR_SECURITY_SESSION_REMEMBER_ME_DAYS) 

144 env_key = f"LDR_{key.upper().replace('.', '_')}" 

145 env_value = check_env_setting(key) 

146 

147 if env_value is not None: 

148 converted = _convert_value(env_value, type(default), env_key) 

149 if converted is not None: 

150 # Validate bounds for numeric types 

151 if isinstance(converted, (int, float)) and ( 151 ↛ 154line 151 didn't jump to line 154 because the condition on line 151 was never true

152 min_value is not None or max_value is not None 

153 ): 

154 converted = _validate_bounds( 

155 converted, min_value, max_value, env_key 

156 ) 

157 return converted # type: ignore 

158 

159 # Load from JSON defaults 

160 if key in settings: 

161 return unwrap_setting(setting_data) 

162 

163 return default