Coverage for .venv/lib/python3.13/site-packages/litellm/proxy/management_endpoints/config_override_endpoints.py: 65%

358 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-10-10 12:01 +0000

1import asyncio 

2import json 

3import os 

4from collections.abc import Mapping, Sequence 

5from datetime import datetime, timezone 

6from types import MappingProxyType 

7from typing import TYPE_CHECKING, Final, Protocol 

8 

9from fastapi import APIRouter, Depends, Header, HTTPException 

10from pydantic import BaseModel, TypeAdapter 

11from typing_extensions import ReadOnly, TypedDict 

12 

13from litellm._uuid import uuid 

14from litellm.litellm_core_utils.safe_json_dumps import safe_dumps 

15from litellm.litellm_core_utils.safe_json_loads import safe_json_loads 

16 

17try: 

18 from prisma.errors import RecordNotFoundError 

19except ImportError: 

20 RecordNotFoundError = Exception 

21 

22import litellm 

23from litellm._logging import verbose_proxy_logger 

24from litellm.litellm_core_utils.sensitive_data_masker import SensitiveDataMasker 

25from litellm.llms.custom_httpx.http_handler import get_async_httpx_client 

26from litellm.proxy._types import ( 

27 AUDIT_ACTIONS, 

28 CommonProxyErrors, 

29 KeyManagementSystem, 

30 LiteLLM_AuditLogs, 

31 LitellmTableNames, 

32 LitellmUserRoles, 

33 UserAPIKeyAuth, 

34) 

35from litellm.proxy.auth.user_api_key_auth import user_api_key_auth 

36from litellm.repositories.table_repositories import ConfigOverridesRepository 

37from litellm.types.llms.custom_http import httpxSpecialProvider 

38from litellm.types.proxy.management_endpoints.config_overrides import ( 

39 ConfigOverrideSettingsResponse, 

40 CyberArkConfig, 

41 HashicorpVaultConfig, 

42) 

43 

44if TYPE_CHECKING: 44 ↛ 45line 44 didn't jump to line 45 because the condition on line 44 was never true

45 from litellm.proxy.proxy_server import ProxyConfig 

46 from litellm.proxy.utils import PrismaClient 

47 

48router: Final = APIRouter() 

49 

50 

51class _ConfigOverrideRow(Protocol): 

52 @property 

53 def config_value(self) -> str | Mapping[str, object] | None: ... 53 ↛ exitline 53 didn't return from function 'config_value' because

54 

55 

56class _ConfigOverridesTableClient(Protocol): 

57 async def find_unique(self, where: Mapping[str, str]) -> _ConfigOverrideRow | None: ... 57 ↛ exitline 57 didn't return from function 'find_unique' because

58 

59 async def upsert(self, where: Mapping[str, str], data: Mapping[str, Mapping[str, str]]) -> object: ... 59 ↛ exitline 59 didn't return from function 'upsert' because

60 

61 async def delete(self, where: Mapping[str, str]) -> object: ... 61 ↛ exitline 61 didn't return from function 'delete' because

62 

63 

64def _config_overrides_table(prisma_client: "PrismaClient") -> _ConfigOverridesTableClient: 

65 return ConfigOverridesRepository(prisma_client).table 

66 

67 

68_AUDIT_REDACTED: Final = "***REDACTED***" 

69 

70 

71def _redact_config(config: Mapping[str, object] | None) -> dict[str, str]: 

72 """Strip values from a config snapshot before audit-log emission. 

73 

74 Hashicorp Vault config carries ``vault_token``, ``approle_secret_id``, 

75 ``client_key`` etc. Persisting them verbatim into ``LiteLLM_AuditLogs`` 

76 would let anyone with read access to the audit table harvest the 

77 proxy's KMS credentials. Keep keys, redact values. 

78 """ 

79 if not config: 

80 return {} 

81 return {k: _AUDIT_REDACTED for k in config} 

82 

83 

84def _log_audit_task_exception(task: "asyncio.Task[None]") -> None: 

85 if task.cancelled(): 

86 return 

87 exc: Final = task.exception() 

88 if exc is not None: 

89 verbose_proxy_logger.warning("Failed to write config override audit log: %s", exc) 

90 

91 

92async def _emit_config_override_audit_log( 

93 *, 

94 object_id: str, 

95 action: AUDIT_ACTIONS, 

96 before_config: Mapping[str, object] | None, 

97 after_config: Mapping[str, object] | None, 

98 user_api_key_dict: UserAPIKeyAuth, 

99 litellm_changed_by: str | None, 

100) -> None: 

101 """Emit an audit-log row for a /config_overrides/{object_id} mutation. 

102 

103 Mirrors the ``store_audit_logs``-gated pattern from 

104 ``team_callback_endpoints.py``. Captured under 

105 ``LiteLLM_ConfigOverrides`` so the row co-locates with the table it 

106 mutates. 

107 """ 

108 from litellm.proxy.management_helpers.audit_logs import ( 

109 create_audit_log_for_update, 

110 is_audit_logging_enabled, 

111 ) 

112 from litellm.proxy.proxy_server import litellm_proxy_admin_name 

113 

114 if not is_audit_logging_enabled(): 114 ↛ 117line 114 didn't jump to line 117 because the condition on line 114 was always true

115 return 

116 

117 task: Final = asyncio.create_task( 

118 create_audit_log_for_update( 

119 request_data=LiteLLM_AuditLogs( 

120 id=str(uuid.uuid4()), 

121 updated_at=datetime.now(timezone.utc), 

122 changed_by=litellm_changed_by or user_api_key_dict.user_id or litellm_proxy_admin_name, 

123 changed_by_api_key=user_api_key_dict.api_key, 

124 table_name=LitellmTableNames.CONFIG_OVERRIDES_TABLE_NAME, 

125 object_id=object_id, 

126 action=action, 

127 updated_values=json.dumps({"config": _redact_config(after_config)}, default=str), 

128 before_value=json.dumps({"config": _redact_config(before_config)}, default=str), 

129 ) 

130 ) 

131 ) 

132 task.add_done_callback(_log_audit_task_exception) 

133 

134 

135# --- Hashicorp Vault constants --- 

136 

137HASHICORP_ENV_VAR_MAPPING: Final[dict[str, str]] = { 

138 "vault_addr": "HCP_VAULT_ADDR", 

139 "vault_token": "HCP_VAULT_TOKEN", 

140 "approle_role_id": "HCP_VAULT_APPROLE_ROLE_ID", 

141 "approle_secret_id": "HCP_VAULT_APPROLE_SECRET_ID", 

142 "approle_mount_path": "HCP_VAULT_APPROLE_MOUNT_PATH", 

143 "client_cert": "HCP_VAULT_CLIENT_CERT", 

144 "client_key": "HCP_VAULT_CLIENT_KEY", 

145 "vault_cert_role": "HCP_VAULT_CERT_ROLE", 

146 "vault_namespace": "HCP_VAULT_NAMESPACE", 

147 "vault_login_namespace": "HCP_VAULT_LOGIN_NAMESPACE", 

148 "vault_secret_namespace": "HCP_VAULT_SECRET_NAMESPACE", 

149 "vault_mount_name": "HCP_VAULT_MOUNT_NAME", 

150 "vault_path_prefix": "HCP_VAULT_PATH_PREFIX", 

151} 

152 

153HASHICORP_SENSITIVE_FIELDS: Final[set[str]] = { 

154 "vault_token", 

155 "approle_secret_id", 

156 "client_key", 

157} 

158 

159# --- CyberArk Conjur constants --- 

160 

161CYBERARK_ENV_VAR_MAPPING: Final[dict[str, str]] = { # mutable-ok: module-level env mapping 

162 "cyberark_api_base": "CYBERARK_API_BASE", 

163 "cyberark_account": "CYBERARK_ACCOUNT", 

164 "cyberark_username": "CYBERARK_USERNAME", 

165 "cyberark_api_key": "CYBERARK_API_KEY", 

166 "client_cert": "CYBERARK_CLIENT_CERT", 

167 "client_key": "CYBERARK_CLIENT_KEY", 

168 "ssl_verify": "CYBERARK_SSL_VERIFY", 

169 "refresh_interval": "CYBERARK_REFRESH_INTERVAL", 

170} 

171 

172CYBERARK_SENSITIVE_FIELDS: Final[set[str]] = { # mutable-ok: module-level constant, mirrors HASHICORP_SENSITIVE_FIELDS 

173 "cyberark_api_key", 

174 "client_key", 

175} 

176 

177_sensitive_masker: Final = SensitiveDataMasker() 

178 

179 

180# --- Shared helpers --- 

181 

182 

183def _mask_sensitive_fields(data: Mapping[str, object], sensitive_fields: set[str]) -> dict[str, object]: 

184 """Mask sensitive fields for API responses. Non-sensitive fields are left as-is.""" 

185 masked: Final[dict[str, object]] = {} 

186 for key, value in data.items(): 

187 if value is not None and key in sensitive_fields and isinstance(value, str): 

188 masked[key] = _sensitive_masker._mask_value(value) 

189 else: 

190 masked[key] = value 

191 return masked 

192 

193 

194def _get_current_env_values(env_var_mapping: dict[str, str]) -> dict[str, str | None]: 

195 """Read current env var values as fallback when no DB record exists.""" 

196 values: Final = {} 

197 for field_name, env_var_name in env_var_mapping.items(): 

198 env_value = os.environ.get(env_var_name) 

199 values[field_name] = env_value 

200 return values 

201 

202 

203class _JsonSchemaField(TypedDict, total=False): 

204 type: ReadOnly[str] 

205 anyOf: ReadOnly[Sequence["_JsonSchemaField"]] 

206 description: ReadOnly[str] 

207 

208 

209def _extract_field_type(field_info: _JsonSchemaField) -> str: 

210 """Extract the non-null type from a Pydantic v2 JSON schema field.""" 

211 if "type" in field_info: 211 ↛ 212line 211 didn't jump to line 212 because the condition on line 211 was never true

212 return field_info["type"] 

213 for option in field_info.get("anyOf", []): 213 ↛ 216line 213 didn't jump to line 216 because the loop on line 213 didn't complete

214 if option.get("type") != "null": 214 ↛ 213line 214 didn't jump to line 213 because the condition on line 214 was always true

215 return option.get("type", "string") 

216 return "string" 

217 

218 

219def _build_field_schema(model_class: type[BaseModel]) -> dict[str, object]: 

220 """Build field_schema dict from a Pydantic model for UI rendering.""" 

221 schema: Final = TypeAdapter(model_class).json_schema(by_alias=True) 

222 raw_properties: Final[Mapping[str, _JsonSchemaField]] = schema.get("properties", {}) 

223 properties: Final = {} 

224 for field_name, field_info in raw_properties.items(): 

225 properties[field_name] = { 

226 "description": field_info.get("description", ""), 

227 "type": _extract_field_type(field_info), 

228 } 

229 return { 

230 "description": schema.get("description", ""), 

231 "properties": properties, 

232 } 

233 

234 

235def _parse_config_value(raw: str | Mapping[str, object]) -> dict[str, object]: 

236 """Parse a config_value from DB (may be JSON string or dict).""" 

237 if isinstance(raw, str): 

238 return safe_json_loads(raw, default={}) 

239 return dict(raw) 

240 

241 

242def _set_env_vars( 

243 config_data: Mapping[str, object], 

244 env_var_mapping: Mapping[str, str] = HASHICORP_ENV_VAR_MAPPING, 

245) -> None: 

246 """Set mapped env vars from config data. Unsets vars for missing/None/empty fields.""" 

247 for field_name, env_var_name in env_var_mapping.items(): 

248 value = config_data.get(field_name) 

249 if value is not None and value != "": 

250 os.environ[env_var_name] = str(value) 

251 else: 

252 os.environ.pop(env_var_name, None) 

253 

254 

255def _clear_hashicorp_vault_state(proxy_config: "ProxyConfig") -> None: 

256 """Clear all Hashicorp Vault state: env vars, secret manager, and change-detection cache.""" 

257 _set_env_vars({}) 

258 if litellm._key_management_system == KeyManagementSystem.HASHICORP_VAULT: 258 ↛ 259line 258 didn't jump to line 259 because the condition on line 258 was never true

259 litellm.secret_manager_client = None 

260 litellm._key_management_system = None 

261 proxy_config._last_hashicorp_vault_config = None # pyright: ignore[reportPrivateUsage] # proxy-internal change-detection cache 

262 

263 

264def _snapshot_cyberark_boot_env(proxy_config: "ProxyConfig") -> None: 

265 """Capture deployment-provided CYBERARK_* env vars once, before the first DB-driven overwrite.""" 

266 if proxy_config._cyberark_boot_env is None: # pyright: ignore[reportPrivateUsage] # proxy-internal boot snapshot 266 ↛ exitline 266 didn't return from function '_snapshot_cyberark_boot_env' because the condition on line 266 was always true

267 proxy_config._cyberark_boot_env = _get_current_env_values(CYBERARK_ENV_VAR_MAPPING) # pyright: ignore[reportPrivateUsage] # proxy-internal boot snapshot 

268 

269 

270def _restore_cyberark_runtime(proxy_config: "ProxyConfig", env_values: Mapping[str, str | None]) -> None: 

271 """Restore CYBERARK_* env vars and reinitialize (or drop) the secret manager to match them.""" 

272 _set_env_vars(env_values, CYBERARK_ENV_VAR_MAPPING) 

273 if env_values.get("cyberark_api_base"): 273 ↛ 274line 273 didn't jump to line 274 because the condition on line 273 was never true

274 try: 

275 proxy_config.initialize_secret_manager(key_management_system="cyberark") 

276 except Exception: # noqa: BLE001 # restore is best-effort; fall through to dropping the manager 

277 verbose_proxy_logger.exception("Failed to restore previous CyberArk configuration") 

278 else: 

279 return 

280 if litellm._key_management_system != KeyManagementSystem.CYBERARK: # pyright: ignore[reportPrivateUsage] # proxy-internal helper, mirrors hashicorp endpoint usage 

281 return 

282 litellm.secret_manager_client = None 

283 litellm._key_management_system = None # pyright: ignore[reportPrivateUsage] # proxy-internal helper, mirrors hashicorp endpoint usage 

284 # Force the vault reload to re-init from its own row so no manager is stranded inactive 

285 proxy_config._last_hashicorp_vault_config = None # pyright: ignore[reportPrivateUsage] # proxy-internal change-detection cache 

286 if os.environ.get("HCP_VAULT_ADDR"): 286 ↛ exitline 286 didn't return from function '_restore_cyberark_runtime' because the condition on line 286 was always true

287 try: 

288 proxy_config.initialize_secret_manager(key_management_system="hashicorp_vault") 

289 except Exception: # noqa: BLE001 # restore is best-effort; the vault reload loop retries from its own row 

290 verbose_proxy_logger.exception("Failed to reinitialize Hashicorp Vault after CyberArk rollback") 

291 

292 

293def _clear_cyberark_state(proxy_config: "ProxyConfig") -> None: 

294 """Drop DB-driven CyberArk state, restoring deployment-provided env vars if any.""" 

295 boot_env: Final[Mapping[str, str | None]] = ( 

296 proxy_config._cyberark_boot_env or {} # pyright: ignore[reportPrivateUsage] # proxy-internal boot snapshot 

297 ) 

298 _restore_cyberark_runtime(proxy_config, boot_env) 

299 proxy_config._last_cyberark_config = None # pyright: ignore[reportPrivateUsage] # proxy-internal helper, mirrors hashicorp endpoint usage 

300 

301 

302async def _persist_cyberark_config( 

303 prisma_client: "PrismaClient", 

304 proxy_config: "ProxyConfig", 

305 config_data: Mapping[str, object], 

306) -> dict[str, object]: 

307 """Encrypt and upsert the CyberArk config row; returns the stored (encrypted) payload.""" 

308 encrypted_data: Final = proxy_config._encrypt_env_variables(dict(config_data)) # pyright: ignore[reportPrivateUsage] # proxy-internal helper, mirrors hashicorp endpoint usage 

309 config_value: Final = safe_dumps(encrypted_data) 

310 await _config_overrides_table(prisma_client).upsert( 

311 where={"config_type": "cyberark"}, # mutable-ok: prisma upsert payload 

312 data={ # mutable-ok: prisma upsert payload 

313 "create": { # mutable-ok: prisma upsert payload 

314 "config_type": "cyberark", 

315 "config_value": config_value, 

316 }, 

317 "update": { # mutable-ok: prisma upsert payload 

318 "config_value": config_value, 

319 }, 

320 }, 

321 ) 

322 return safe_json_loads(config_value) 

323 

324 

325# --- Hashicorp Vault endpoints --- 

326 

327 

328@router.post( 

329 "/config_overrides/hashicorp_vault", 

330 tags=["Config Overrides"], 

331 dependencies=[Depends(user_api_key_auth)], 

332) 

333async def update_hashicorp_vault_config( 

334 config: HashicorpVaultConfig, 

335 user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), 

336 litellm_changed_by: str | None = Header( 

337 None, 

338 description="The litellm-changed-by header enables tracking of actions performed by authorized users on behalf of other users, providing an audit trail for accountability", 

339 ), 

340): 

341 """ 

342 Update Hashicorp Vault secret manager configuration. 

343 Sets environment variables, encrypts sensitive fields, and stores in DB. 

344 Reinitializes the secret manager on this pod. 

345 """ 

346 from litellm.proxy.proxy_server import prisma_client, proxy_config 

347 

348 if user_api_key_dict.user_role != LitellmUserRoles.PROXY_ADMIN: 348 ↛ 349line 348 didn't jump to line 349 because the condition on line 348 was never true

349 raise HTTPException( 

350 status_code=403, 

351 detail="Only admin users can update config overrides", 

352 ) 

353 

354 if prisma_client is None: 354 ↛ 355line 354 didn't jump to line 355 because the condition on line 354 was never true

355 raise HTTPException( 

356 status_code=500, 

357 detail=CommonProxyErrors.db_not_connected_error.value, 

358 ) 

359 

360 config_data: dict[str, object] = config.model_dump(exclude_none=True) 

361 

362 # Merge ALL fields the user didn't send: try DB first, fall back to env vars. 

363 # Omitted field = keep existing; empty string = clear/remove the field. 

364 existing_record: Final = await _config_overrides_table(prisma_client).find_unique( 

365 where={"config_type": "hashicorp_vault"} 

366 ) 

367 existing_decrypted: dict[str, object] | None = None 

368 env_values: dict[str, str | None] = {} 

369 if existing_record is not None and existing_record.config_value is not None: 369 ↛ 370line 369 didn't jump to line 370 because the condition on line 369 was never true

370 existing_data: Final = _parse_config_value(existing_record.config_value) 

371 existing_decrypted = proxy_config._decrypt_db_variables(existing_data) 

372 for field in HASHICORP_ENV_VAR_MAPPING: 

373 if field not in config_data and existing_decrypted.get(field): 

374 config_data[field] = existing_decrypted[field] 

375 else: 

376 # No DB record (or DB record with null config_value) — merge from 

377 # current env vars instead. 

378 env_values = _get_current_env_values(HASHICORP_ENV_VAR_MAPPING) 

379 for field in HASHICORP_ENV_VAR_MAPPING: 

380 if field not in config_data and env_values.get(field): 

381 config_data[field] = env_values[field] 

382 

383 # Strip empty strings — they signal "clear this field" 

384 config_data = {k: v for k, v in config_data.items() if v != ""} 

385 

386 # Validate that the config has enough fields to initialize 

387 has_vault_addr: Final = bool(config_data.get("vault_addr")) 

388 has_token_auth: Final = bool(config_data.get("vault_token")) 

389 has_approle_auth: Final = bool(config_data.get("approle_role_id") and config_data.get("approle_secret_id")) 

390 has_tls_cert_auth: Final = bool(config_data.get("client_cert") and config_data.get("client_key")) 

391 

392 if not has_vault_addr: 

393 raise HTTPException( 

394 status_code=400, 

395 detail="Vault Address is required", 

396 ) 

397 

398 if not has_token_auth and not has_approle_auth and not has_tls_cert_auth: 

399 raise HTTPException( 

400 status_code=400, 

401 detail="At least one authentication method is required: " 

402 "provide a Token, both AppRole Role ID and Secret ID, " 

403 "or both Client Certificate and Client Key", 

404 ) 

405 

406 # Snapshot current env vars so we can restore on failure 

407 previous_env: Final = _get_current_env_values(HASHICORP_ENV_VAR_MAPPING) 

408 

409 # Set env vars and verify the secret manager can initialize before persisting 

410 _set_env_vars(config_data) 

411 

412 try: 

413 proxy_config.initialize_secret_manager(key_management_system="hashicorp_vault") 

414 except Exception as e: 

415 _set_env_vars(previous_env) 

416 verbose_proxy_logger.exception("Error reinitializing Hashicorp Vault secret manager: %s", str(e)) 

417 raise HTTPException( 

418 status_code=500, 

419 detail=f"Failed to initialize secret manager: {e}", 

420 ) 

421 

422 # Only persist to DB after successful init 

423 encrypted_data: Final = proxy_config._encrypt_env_variables(config_data) 

424 config_value: Final = safe_dumps(encrypted_data) 

425 await _config_overrides_table(prisma_client).upsert( 

426 where={"config_type": "hashicorp_vault"}, 

427 data={ 

428 "create": { 

429 "config_type": "hashicorp_vault", 

430 "config_value": config_value, 

431 }, 

432 "update": { 

433 "config_value": config_value, 

434 }, 

435 }, 

436 ) 

437 

438 # Update change-detection cache so the background reload doesn't redundantly re-init 

439 proxy_config._last_hashicorp_vault_config = safe_json_loads(config_value) 

440 

441 # Mutating the proxy's KMS config affects every secret retrieval going 

442 # forward — emit an audit-log row so the action is traceable even 

443 # though the secret_manager_client itself was just swapped under us. 

444 # Action keys off row existence (a row with NULL ``config_value`` is 

445 # still an update). ``before_config`` falls back to env vars when the 

446 # row was absent or its ``config_value`` was NULL. 

447 before_config: Final = existing_decrypted if existing_decrypted is not None else env_values 

448 action: Final[AUDIT_ACTIONS] = "updated" if existing_record is not None else "created" 

449 await _emit_config_override_audit_log( 

450 object_id="hashicorp_vault", 

451 action=action, 

452 before_config=before_config, 

453 after_config=config_data, 

454 user_api_key_dict=user_api_key_dict, 

455 litellm_changed_by=litellm_changed_by, 

456 ) 

457 

458 return { 

459 "message": "Hashicorp Vault configuration updated successfully", 

460 "status": "success", 

461 } 

462 

463 

464@router.get( 

465 "/config_overrides/hashicorp_vault", 

466 tags=["Config Overrides"], 

467 dependencies=[Depends(user_api_key_auth)], 

468 response_model=ConfigOverrideSettingsResponse, 

469) 

470async def get_hashicorp_vault_config( 

471 user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), 

472): 

473 """ 

474 Get current Hashicorp Vault configuration. 

475 Returns decrypted values from DB, or falls back to current env vars. 

476 """ 

477 from litellm.proxy.management_endpoints.common_utils import _user_has_admin_view 

478 from litellm.proxy.proxy_server import prisma_client, proxy_config 

479 

480 # Admin Viewer follows the read-parity rule. 

481 if not _user_has_admin_view(user_api_key_dict): 481 ↛ 482line 481 didn't jump to line 482 because the condition on line 481 was never true

482 raise HTTPException( 

483 status_code=403, 

484 detail="Only admin users can view config overrides", 

485 ) 

486 

487 if prisma_client is None: 487 ↛ 488line 487 didn't jump to line 488 because the condition on line 487 was never true

488 raise HTTPException( 

489 status_code=500, 

490 detail=CommonProxyErrors.db_not_connected_error.value, 

491 ) 

492 

493 field_schema: Final = _build_field_schema(HashicorpVaultConfig) 

494 

495 # Try to load from DB 

496 db_record: Final = await _config_overrides_table(prisma_client).find_unique( 

497 where={"config_type": "hashicorp_vault"} 

498 ) 

499 

500 if db_record is not None and db_record.config_value is not None: 500 ↛ 501line 500 didn't jump to line 501 because the condition on line 500 was never true

501 config_data: Final = _parse_config_value(db_record.config_value) 

502 

503 # Decrypt then mask sensitive fields so plaintext secrets are never sent to the UI 

504 decrypted_data: Final[Mapping[str, object]] = proxy_config._decrypt_db_variables(config_data) 

505 masked_data: Final = _mask_sensitive_fields(decrypted_data, HASHICORP_SENSITIVE_FIELDS) 

506 

507 return ConfigOverrideSettingsResponse( 

508 config_type="hashicorp_vault", 

509 values=masked_data, 

510 field_schema=field_schema, 

511 ) 

512 

513 # Fallback to env vars — also mask sensitive values 

514 env_values: Final = _get_current_env_values(HASHICORP_ENV_VAR_MAPPING) 

515 masked_env_values: Final = _mask_sensitive_fields(env_values, HASHICORP_SENSITIVE_FIELDS) 

516 

517 return ConfigOverrideSettingsResponse( 

518 config_type="hashicorp_vault", 

519 values=masked_env_values, 

520 field_schema=field_schema, 

521 ) 

522 

523 

524@router.delete( 

525 "/config_overrides/hashicorp_vault", 

526 tags=["Config Overrides"], 

527 dependencies=[Depends(user_api_key_auth)], 

528) 

529async def delete_hashicorp_vault_config( 

530 user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), 

531 litellm_changed_by: str | None = Header( 

532 None, 

533 description="The litellm-changed-by header enables tracking of actions performed by authorized users on behalf of other users, providing an audit trail for accountability", 

534 ), 

535): 

536 """Delete Hashicorp Vault configuration. Idempotent.""" 

537 from litellm.proxy.proxy_server import prisma_client, proxy_config 

538 

539 if user_api_key_dict.user_role != LitellmUserRoles.PROXY_ADMIN: 539 ↛ 540line 539 didn't jump to line 540 because the condition on line 539 was never true

540 raise HTTPException( 

541 status_code=403, 

542 detail="Only admin users can delete config overrides", 

543 ) 

544 

545 if prisma_client is None: 545 ↛ 546line 545 didn't jump to line 546 because the condition on line 545 was never true

546 raise HTTPException( 

547 status_code=500, 

548 detail=CommonProxyErrors.db_not_connected_error.value, 

549 ) 

550 

551 # Capture the prior config before delete so the audit-log row can 

552 # show *what* was removed (keys only — values get redacted). 

553 existing_record: Final = await _config_overrides_table(prisma_client).find_unique( 

554 where={"config_type": "hashicorp_vault"} 

555 ) 

556 before_config: dict[str, object] | None = None 

557 if existing_record is not None and existing_record.config_value is not None: 557 ↛ 558line 557 didn't jump to line 558 because the condition on line 557 was never true

558 try: 

559 before_config = proxy_config._decrypt_db_variables(_parse_config_value(existing_record.config_value)) 

560 except Exception: 

561 before_config = None 

562 

563 # Delete DB record if it exists — ignore if not found 

564 deleted = False 

565 try: 

566 await _config_overrides_table(prisma_client).delete(where={"config_type": "hashicorp_vault"}) 

567 deleted = True 

568 except RecordNotFoundError: 

569 verbose_proxy_logger.debug("No existing Hashicorp Vault config record to delete") 

570 

571 _clear_hashicorp_vault_state(proxy_config) 

572 

573 # Only emit audit log if a row was actually removed; an idempotent 

574 # delete on a non-existent row produces no security-relevant change. 

575 if deleted: 575 ↛ 585line 575 didn't jump to line 585 because the condition on line 575 was always true

576 await _emit_config_override_audit_log( 

577 object_id="hashicorp_vault", 

578 action="deleted", 

579 before_config=before_config, 

580 after_config=None, 

581 user_api_key_dict=user_api_key_dict, 

582 litellm_changed_by=litellm_changed_by, 

583 ) 

584 

585 return { 

586 "message": "Hashicorp Vault configuration deleted successfully", 

587 "status": "success", 

588 } 

589 

590 

591@router.post( 

592 "/config_overrides/hashicorp_vault/test_connection", 

593 tags=["Config Overrides"], 

594 dependencies=[Depends(user_api_key_auth)], 

595) 

596async def test_hashicorp_vault_connection( 

597 user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), 

598): 

599 """ 

600 Test the connection to the currently configured Hashicorp Vault. 

601 Uses the already-initialized secret manager client. Does not modify any state. 

602 """ 

603 from litellm.secret_managers.hashicorp_secret_manager import ( 

604 HashicorpSecretManager, 

605 ) 

606 

607 if user_api_key_dict.user_role != LitellmUserRoles.PROXY_ADMIN: 607 ↛ 608line 607 didn't jump to line 608 because the condition on line 607 was never true

608 raise HTTPException( 

609 status_code=403, 

610 detail="Only admin users can test Vault connection", 

611 ) 

612 

613 client: Final = litellm.secret_manager_client 

614 if not isinstance(client, HashicorpSecretManager): 614 ↛ 621line 614 didn't jump to line 621 because the condition on line 614 was always true

615 raise HTTPException( 

616 status_code=400, 

617 detail="Hashicorp Vault is not configured. Save a configuration first.", 

618 ) 

619 

620 # Step 1: Authenticate (exercises AppRole login, TLS cert login, or direct token) 

621 try: 

622 headers: Final[Mapping[str, str]] = await asyncio.to_thread(client._get_request_headers) 

623 except Exception as e: 

624 raise HTTPException( 

625 status_code=502, 

626 detail=f"Vault authentication failed: {e}", 

627 ) 

628 

629 # Step 2: Verify the token is valid via token/lookup-self 

630 try: 

631 async_client: Final = get_async_httpx_client(llm_provider=httpxSpecialProvider.SecretManager) 

632 lookup_url: Final = f"{client.vault_addr}/v1/auth/token/lookup-self" 

633 lookup_headers: Final[Mapping[str, str]] = MappingProxyType({**headers, **client._get_login_headers()}) 

634 response: Final = await async_client.get(lookup_url, headers=lookup_headers) 

635 response.raise_for_status() 

636 except Exception as e: 

637 raise HTTPException( 

638 status_code=502, 

639 detail=f"Vault token validation failed: {e}", 

640 ) 

641 

642 return { 

643 "status": "success", 

644 "message": f"Successfully connected to Vault at {client.vault_addr}", 

645 } 

646 

647 

648# --- CyberArk Conjur endpoints --- 

649 

650 

651@router.post( 

652 "/config_overrides/cyberark", 

653 tags=["Config Overrides"], # mutable-ok: FastAPI route decorator metadata 

654 dependencies=[Depends(user_api_key_auth)], # mutable-ok: FastAPI route decorator metadata 

655) 

656async def update_cyberark_config( 

657 config: CyberArkConfig, 

658 user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), # noqa: B008 # FastAPI dependency injection 

659 litellm_changed_by: str | None = Header( 

660 None, 

661 description="The litellm-changed-by header enables tracking of actions performed by authorized users on behalf of other users, providing an audit trail for accountability", 

662 ), 

663) -> dict[str, str]: 

664 """ 

665 Update CyberArk Conjur secret manager configuration. 

666 Sets environment variables, encrypts sensitive fields, and stores in DB. 

667 Reinitializes the secret manager on this pod. 

668 """ 

669 from litellm.proxy.proxy_server import prisma_client, proxy_config 

670 

671 if user_api_key_dict.user_role != LitellmUserRoles.PROXY_ADMIN: 671 ↛ 672line 671 didn't jump to line 672 because the condition on line 671 was never true

672 raise HTTPException( 

673 status_code=403, 

674 detail="Only admin users can update config overrides", 

675 ) 

676 

677 if prisma_client is None: 677 ↛ 678line 677 didn't jump to line 678 because the condition on line 677 was never true

678 raise HTTPException( 

679 status_code=500, 

680 detail=CommonProxyErrors.db_not_connected_error.value, 

681 ) 

682 

683 config_data: dict[str, object] = config.model_dump(exclude_none=True) # mutable-ok: merged # rebind-ok: stripped 

684 

685 # Merge ALL fields the user didn't send: try DB first, fall back to env vars. 

686 # Omitted field = keep existing; empty string = clear/remove the field. 

687 existing_record: Final = await _config_overrides_table(prisma_client).find_unique( 

688 where={"config_type": "cyberark"} # mutable-ok: prisma where clause 

689 ) 

690 existing_decrypted: dict[str, object] | None = None # mutable-ok: DB payload # rebind-ok: set when record exists 

691 env_values: dict[str, str | None] = {} # mutable-ok: env snapshot # rebind-ok: populated when no DB record exists 

692 if existing_record is not None and existing_record.config_value is not None: 692 ↛ 693line 692 didn't jump to line 693 because the condition on line 692 was never true

693 existing_data: Final = _parse_config_value(existing_record.config_value) 

694 existing_decrypted = proxy_config._decrypt_db_variables(existing_data) # pyright: ignore[reportPrivateUsage] # rebind-ok: populated when a prior record decrypts 

695 for field in CYBERARK_ENV_VAR_MAPPING: 

696 if field not in config_data and existing_decrypted.get(field): 

697 config_data[field] = existing_decrypted[field] 

698 else: 

699 env_values = _get_current_env_values(CYBERARK_ENV_VAR_MAPPING) # rebind-ok: populated when no DB record exists 

700 for field in CYBERARK_ENV_VAR_MAPPING: 

701 if field not in config_data and env_values.get(field): 701 ↛ 702line 701 didn't jump to line 702 because the condition on line 701 was never true

702 config_data[field] = env_values[field] 

703 

704 config_data = {k: v for k, v in config_data.items() if v != ""} # mutable-ok: dict # rebind-ok: "" means clear 

705 

706 has_api_base: Final = bool(config_data.get("cyberark_api_base")) 

707 has_api_key_auth: Final = bool(config_data.get("cyberark_api_key")) 

708 has_tls_cert_auth: Final = bool(config_data.get("client_cert") and config_data.get("client_key")) 

709 

710 if not has_api_base: 

711 raise HTTPException( 

712 status_code=400, 

713 detail="CyberArk API Base is required", 

714 ) 

715 

716 if not has_api_key_auth and not has_tls_cert_auth: 

717 raise HTTPException( 

718 status_code=400, 

719 detail="At least one authentication method is required: " 

720 "provide an API Key, or both Client Certificate and Client Key", 

721 ) 

722 

723 _snapshot_cyberark_boot_env(proxy_config) 

724 previous_env: Final = _get_current_env_values(CYBERARK_ENV_VAR_MAPPING) 

725 _set_env_vars(config_data, CYBERARK_ENV_VAR_MAPPING) 

726 

727 try: 

728 proxy_config.initialize_secret_manager(key_management_system="cyberark") 

729 except Exception as e: # noqa: BLE001 # any init failure must roll back env vars 

730 _set_env_vars(previous_env, CYBERARK_ENV_VAR_MAPPING) 

731 verbose_proxy_logger.exception("Error reinitializing CyberArk secret manager: %s", str(e)) 

732 raise HTTPException( 

733 status_code=500, 

734 detail=f"Failed to initialize secret manager: {e}", 

735 ) 

736 

737 try: 

738 proxy_config._last_cyberark_config = await _persist_cyberark_config( # pyright: ignore[reportPrivateUsage] # proxy-internal helper, mirrors hashicorp endpoint usage 

739 prisma_client, proxy_config, config_data 

740 ) 

741 except Exception as e: # noqa: BLE001 # persistence failure must roll back the runtime state set above 

742 _restore_cyberark_runtime(proxy_config, previous_env) 

743 verbose_proxy_logger.exception("Error persisting CyberArk configuration: %s", str(e)) 

744 raise HTTPException( 

745 status_code=500, 

746 detail=f"Failed to persist CyberArk configuration: {e}", 

747 ) 

748 

749 before_config: Final = existing_decrypted if existing_decrypted is not None else env_values 

750 action: Final[AUDIT_ACTIONS] = "updated" if existing_record is not None else "created" 

751 await _emit_config_override_audit_log( 

752 object_id="cyberark", 

753 action=action, 

754 before_config=before_config, 

755 after_config=config_data, 

756 user_api_key_dict=user_api_key_dict, 

757 litellm_changed_by=litellm_changed_by, 

758 ) 

759 

760 return { # mutable-ok: JSON response payload 

761 "message": "CyberArk configuration updated successfully", 

762 "status": "success", 

763 } 

764 

765 

766@router.get( 

767 "/config_overrides/cyberark", 

768 tags=["Config Overrides"], # mutable-ok: FastAPI route decorator metadata 

769 dependencies=[Depends(user_api_key_auth)], # mutable-ok: FastAPI route decorator metadata 

770 response_model=ConfigOverrideSettingsResponse, 

771) 

772async def get_cyberark_config( 

773 user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), # noqa: B008 # FastAPI dependency injection 

774) -> ConfigOverrideSettingsResponse: 

775 """ 

776 Get current CyberArk Conjur configuration. 

777 Returns decrypted values from DB, or falls back to current env vars. 

778 Sensitive fields are masked before leaving the server. 

779 """ 

780 from litellm.proxy.management_endpoints.common_utils import ( 

781 _user_has_admin_view, # pyright: ignore[reportPrivateUsage] # proxy-internal helper, mirrors hashicorp endpoint usage 

782 ) 

783 from litellm.proxy.proxy_server import prisma_client, proxy_config 

784 

785 if not _user_has_admin_view(user_api_key_dict): 785 ↛ 786line 785 didn't jump to line 786 because the condition on line 785 was never true

786 raise HTTPException( 

787 status_code=403, 

788 detail="Only admin users can view config overrides", 

789 ) 

790 

791 if prisma_client is None: 791 ↛ 792line 791 didn't jump to line 792 because the condition on line 791 was never true

792 raise HTTPException( 

793 status_code=500, 

794 detail=CommonProxyErrors.db_not_connected_error.value, 

795 ) 

796 

797 field_schema: Final = _build_field_schema(CyberArkConfig) 

798 

799 db_record: Final = await _config_overrides_table(prisma_client).find_unique(where={"config_type": "cyberark"}) 

800 

801 if db_record is not None and db_record.config_value is not None: 801 ↛ 802line 801 didn't jump to line 802 because the condition on line 801 was never true

802 config_data: Final = _parse_config_value(db_record.config_value) 

803 decrypted_data: Final[Mapping[str, object]] = proxy_config._decrypt_db_variables(config_data) # pyright: ignore[reportPrivateUsage] # proxy-internal helper, mirrors hashicorp endpoint usage 

804 masked_data: Final = _mask_sensitive_fields(decrypted_data, CYBERARK_SENSITIVE_FIELDS) 

805 

806 return ConfigOverrideSettingsResponse( 

807 config_type="cyberark", 

808 values=masked_data, 

809 field_schema=field_schema, 

810 ) 

811 

812 env_values: Final = _get_current_env_values(CYBERARK_ENV_VAR_MAPPING) 

813 masked_env_values: Final = _mask_sensitive_fields(env_values, CYBERARK_SENSITIVE_FIELDS) 

814 

815 return ConfigOverrideSettingsResponse( 

816 config_type="cyberark", 

817 values=masked_env_values, 

818 field_schema=field_schema, 

819 ) 

820 

821 

822@router.delete( 

823 "/config_overrides/cyberark", 

824 tags=["Config Overrides"], # mutable-ok: FastAPI route decorator metadata 

825 dependencies=[Depends(user_api_key_auth)], # mutable-ok: FastAPI route decorator metadata 

826) 

827async def delete_cyberark_config( 

828 user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), # noqa: B008 # FastAPI dependency injection 

829 litellm_changed_by: str | None = Header( 

830 None, 

831 description="The litellm-changed-by header enables tracking of actions performed by authorized users on behalf of other users, providing an audit trail for accountability", 

832 ), 

833) -> dict[str, str]: 

834 """Delete CyberArk Conjur configuration. Idempotent.""" 

835 from litellm.proxy.proxy_server import prisma_client, proxy_config 

836 

837 if user_api_key_dict.user_role != LitellmUserRoles.PROXY_ADMIN: 837 ↛ 838line 837 didn't jump to line 838 because the condition on line 837 was never true

838 raise HTTPException( 

839 status_code=403, 

840 detail="Only admin users can delete config overrides", 

841 ) 

842 

843 if prisma_client is None: 843 ↛ 844line 843 didn't jump to line 844 because the condition on line 843 was never true

844 raise HTTPException( 

845 status_code=500, 

846 detail=CommonProxyErrors.db_not_connected_error.value, 

847 ) 

848 

849 existing_record: Final = await _config_overrides_table(prisma_client).find_unique( 

850 where={"config_type": "cyberark"} # mutable-ok: prisma where clause 

851 ) 

852 before_config: dict[str, object] | None = None # mutable-ok: audit snapshot # rebind-ok: set when decrypts 

853 if existing_record is not None and existing_record.config_value is not None: 853 ↛ 854line 853 didn't jump to line 854 because the condition on line 853 was never true

854 try: 

855 before_config = proxy_config._decrypt_db_variables(_parse_config_value(existing_record.config_value)) # pyright: ignore[reportPrivateUsage] # rebind-ok: populated when the prior record decrypts 

856 except Exception: # noqa: BLE001 # undecryptable prior config must not block deletion 

857 before_config = None # rebind-ok: reset when decryption fails 

858 

859 deleted = False # rebind-ok: set true once the DB row is removed 

860 try: 

861 await _config_overrides_table(prisma_client).delete(where={"config_type": "cyberark"}) 

862 deleted = True # rebind-ok: set true once the DB row is removed 

863 except RecordNotFoundError: 

864 verbose_proxy_logger.debug("No existing CyberArk config record to delete") 

865 

866 _clear_cyberark_state(proxy_config) 

867 

868 if deleted: 868 ↛ 878line 868 didn't jump to line 878 because the condition on line 868 was always true

869 await _emit_config_override_audit_log( 

870 object_id="cyberark", 

871 action="deleted", 

872 before_config=before_config, 

873 after_config=None, 

874 user_api_key_dict=user_api_key_dict, 

875 litellm_changed_by=litellm_changed_by, 

876 ) 

877 

878 return { # mutable-ok: JSON response payload 

879 "message": "CyberArk configuration deleted successfully", 

880 "status": "success", 

881 } 

882 

883 

884@router.post( 

885 "/config_overrides/cyberark/test_connection", 

886 tags=["Config Overrides"], # mutable-ok: FastAPI route decorator metadata 

887 dependencies=[Depends(user_api_key_auth)], # mutable-ok: FastAPI route decorator metadata 

888) 

889async def test_cyberark_connection( 

890 user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), # noqa: B008 # FastAPI dependency injection 

891) -> dict[str, str]: 

892 """ 

893 Test the connection to the currently configured CyberArk Conjur server. 

894 Uses the already-initialized secret manager client. Does not modify any state. 

895 """ 

896 from litellm.secret_managers.cyberark_secret_manager import CyberArkSecretManager 

897 

898 if user_api_key_dict.user_role != LitellmUserRoles.PROXY_ADMIN: 898 ↛ 899line 898 didn't jump to line 899 because the condition on line 898 was never true

899 raise HTTPException( 

900 status_code=403, 

901 detail="Only admin users can test CyberArk connection", 

902 ) 

903 

904 client: Final = litellm.secret_manager_client 

905 if not isinstance(client, CyberArkSecretManager): 

906 raise HTTPException( 

907 status_code=400, 

908 detail="CyberArk is not configured. Save a configuration first.", 

909 ) 

910 

911 try: 

912 headers: Final[Mapping[str, str]] = await asyncio.to_thread(client._get_request_headers) # pyright: ignore[reportPrivateUsage] # proxy-internal helper, mirrors hashicorp endpoint usage 

913 except Exception as e: # noqa: BLE001 # surface any auth failure as a 502 with detail 

914 raise HTTPException( 

915 status_code=502, 

916 detail=f"CyberArk authentication failed: {e}", 

917 ) 

918 

919 try: 

920 async_client: Final = get_async_httpx_client( 

921 llm_provider=httpxSpecialProvider.SecretManager, 

922 params={"ssl_verify": client.ssl_verify}, # mutable-ok: httpx client params 

923 ) 

924 whoami_url: Final = f"{client.conjur_addr}/whoami" 

925 response: Final = await async_client.get(whoami_url, headers=headers) 

926 response.raise_for_status() 

927 except Exception as e: # noqa: BLE001 # surface any connectivity/TLS failure as a 502 with detail 

928 raise HTTPException( 

929 status_code=502, 

930 detail=f"CyberArk token validation failed: {e}", 

931 ) 

932 

933 return { # mutable-ok: JSON response payload 

934 "status": "success", 

935 "message": f"Successfully connected to CyberArk Conjur at {client.conjur_addr}", 

936 }