Coverage for src / local_deep_research / security / security_settings.py: 92%
56 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-14 23:55 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-14 23:55 +0000
1"""Security settings utilities.
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"""
8import json
9from functools import lru_cache
10from pathlib import Path
11from typing import Any, Optional, TypeVar, Union
13from loguru import logger
15from local_deep_research.settings.manager import check_env_setting
17T = TypeVar("T", int, float, str, bool)
19# Path to security settings JSON
20_SETTINGS_PATH = (
21 Path(__file__).parent.parent / "defaults" / "settings_security.json"
22)
25@lru_cache(maxsize=1)
26def _load_security_settings() -> dict:
27 """Load and cache security settings from JSON file.
29 Returns:
30 Dictionary of security settings, or empty dict on error.
31 """
32 try:
33 if _SETTINGS_PATH.exists():
34 with open(_SETTINGS_PATH, "r") as f:
35 return json.load(f)
36 except Exception:
37 logger.warning(
38 f"Failed to load security settings from {_SETTINGS_PATH}"
39 )
40 return {}
43def _convert_value(value: str, target_type: type, key: str) -> Optional[Any]:
44 """Convert string value to target type with proper error handling.
46 Args:
47 value: String value to convert
48 target_type: Target type (int, float, str, bool)
49 key: Setting key for error messages
51 Returns:
52 Converted value or None if conversion fails
53 """
54 try:
55 if target_type is bool:
56 # Handle boolean strings properly
57 return value.lower() in ("true", "1", "yes", "on")
58 if target_type is int:
59 return int(value)
60 if target_type is float:
61 return float(value)
62 return str(value)
63 except (ValueError, TypeError):
64 logger.warning(
65 f"Invalid value for {key}: '{value}' cannot be converted to {target_type.__name__}"
66 )
67 return None
70def _validate_bounds(
71 value: Union[int, float],
72 min_value: Optional[Union[int, float]],
73 max_value: Optional[Union[int, float]],
74 key: str,
75) -> Union[int, float]:
76 """Validate that value is within min/max bounds.
78 Args:
79 value: Value to validate
80 min_value: Minimum allowed value (inclusive)
81 max_value: Maximum allowed value (inclusive)
82 key: Setting key for error messages
84 Returns:
85 Clamped value within bounds
86 """
87 original = value
89 if min_value is not None and value < min_value:
90 value = min_value
91 logger.warning(
92 f"Value {original} for {key} is below minimum {min_value}, using {value}"
93 )
95 if max_value is not None and value > max_value:
96 value = max_value
97 logger.warning(
98 f"Value {original} for {key} is above maximum {max_value}, using {value}"
99 )
101 return value
104def get_security_default(key: str, default: T) -> T:
105 """Load a security setting with environment variable override.
107 Priority order:
108 1. Environment variable (LDR_SECURITY_<KEY>)
109 2. JSON defaults file value
110 3. Provided default
112 Environment variables are validated against min/max bounds defined
113 in the JSON settings file.
115 Args:
116 key: Setting key (e.g., "security.session_remember_me_days")
117 default: Default value if setting not found (also determines type)
119 Returns:
120 Setting value of same type as default
122 Example:
123 >>> get_security_default("security.session_remember_me_days", 30)
124 30 # or value from env/JSON
125 """
126 settings = _load_security_settings()
127 setting_data = settings.get(key, {})
129 # Get min/max bounds from settings schema
130 min_value = (
131 setting_data.get("min_value")
132 if isinstance(setting_data, dict)
133 else None
134 )
135 max_value = (
136 setting_data.get("max_value")
137 if isinstance(setting_data, dict)
138 else None
139 )
141 # Check environment variable first (e.g., LDR_SECURITY_SESSION_REMEMBER_ME_DAYS)
142 env_key = f"LDR_{key.upper().replace('.', '_')}"
143 env_value = check_env_setting(key)
145 if env_value is not None:
146 converted = _convert_value(env_value, type(default), env_key)
147 if converted is not None:
148 # Validate bounds for numeric types
149 if isinstance(converted, (int, float)) and ( 149 ↛ 152line 149 didn't jump to line 152 because the condition on line 149 was never true
150 min_value is not None or max_value is not None
151 ):
152 converted = _validate_bounds(
153 converted, min_value, max_value, env_key
154 )
155 return converted # type: ignore
157 # Load from JSON defaults
158 if key in settings:
159 if isinstance(setting_data, dict) and "value" in setting_data: 159 ↛ 161line 159 didn't jump to line 161 because the condition on line 159 was always true
160 return setting_data["value"]
161 return setting_data
163 return default