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
« 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
9from fastapi import APIRouter, Depends, Header, HTTPException
10from pydantic import BaseModel, TypeAdapter
11from typing_extensions import ReadOnly, TypedDict
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
17try:
18 from prisma.errors import RecordNotFoundError
19except ImportError:
20 RecordNotFoundError = Exception
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)
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
48router: Final = APIRouter()
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
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
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
61 async def delete(self, where: Mapping[str, str]) -> object: ... 61 ↛ exitline 61 didn't return from function 'delete' because
64def _config_overrides_table(prisma_client: "PrismaClient") -> _ConfigOverridesTableClient:
65 return ConfigOverridesRepository(prisma_client).table
68_AUDIT_REDACTED: Final = "***REDACTED***"
71def _redact_config(config: Mapping[str, object] | None) -> dict[str, str]:
72 """Strip values from a config snapshot before audit-log emission.
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}
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)
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.
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
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
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)
135# --- Hashicorp Vault constants ---
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}
153HASHICORP_SENSITIVE_FIELDS: Final[set[str]] = {
154 "vault_token",
155 "approle_secret_id",
156 "client_key",
157}
159# --- CyberArk Conjur constants ---
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}
172CYBERARK_SENSITIVE_FIELDS: Final[set[str]] = { # mutable-ok: module-level constant, mirrors HASHICORP_SENSITIVE_FIELDS
173 "cyberark_api_key",
174 "client_key",
175}
177_sensitive_masker: Final = SensitiveDataMasker()
180# --- Shared helpers ---
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
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
203class _JsonSchemaField(TypedDict, total=False):
204 type: ReadOnly[str]
205 anyOf: ReadOnly[Sequence["_JsonSchemaField"]]
206 description: ReadOnly[str]
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"
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 }
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)
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)
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
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
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")
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
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)
325# --- Hashicorp Vault endpoints ---
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
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 )
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 )
360 config_data: dict[str, object] = config.model_dump(exclude_none=True)
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]
383 # Strip empty strings — they signal "clear this field"
384 config_data = {k: v for k, v in config_data.items() if v != ""}
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"))
392 if not has_vault_addr:
393 raise HTTPException(
394 status_code=400,
395 detail="Vault Address is required",
396 )
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 )
406 # Snapshot current env vars so we can restore on failure
407 previous_env: Final = _get_current_env_values(HASHICORP_ENV_VAR_MAPPING)
409 # Set env vars and verify the secret manager can initialize before persisting
410 _set_env_vars(config_data)
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 )
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 )
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)
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 )
458 return {
459 "message": "Hashicorp Vault configuration updated successfully",
460 "status": "success",
461 }
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
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 )
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 )
493 field_schema: Final = _build_field_schema(HashicorpVaultConfig)
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 )
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)
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)
507 return ConfigOverrideSettingsResponse(
508 config_type="hashicorp_vault",
509 values=masked_data,
510 field_schema=field_schema,
511 )
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)
517 return ConfigOverrideSettingsResponse(
518 config_type="hashicorp_vault",
519 values=masked_env_values,
520 field_schema=field_schema,
521 )
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
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 )
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 )
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
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")
571 _clear_hashicorp_vault_state(proxy_config)
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 )
585 return {
586 "message": "Hashicorp Vault configuration deleted successfully",
587 "status": "success",
588 }
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 )
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 )
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 )
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 )
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 )
642 return {
643 "status": "success",
644 "message": f"Successfully connected to Vault at {client.vault_addr}",
645 }
648# --- CyberArk Conjur endpoints ---
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
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 )
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 )
683 config_data: dict[str, object] = config.model_dump(exclude_none=True) # mutable-ok: merged # rebind-ok: stripped
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]
704 config_data = {k: v for k, v in config_data.items() if v != ""} # mutable-ok: dict # rebind-ok: "" means clear
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"))
710 if not has_api_base:
711 raise HTTPException(
712 status_code=400,
713 detail="CyberArk API Base is required",
714 )
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 )
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)
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 )
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 )
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 )
760 return { # mutable-ok: JSON response payload
761 "message": "CyberArk configuration updated successfully",
762 "status": "success",
763 }
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
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 )
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 )
797 field_schema: Final = _build_field_schema(CyberArkConfig)
799 db_record: Final = await _config_overrides_table(prisma_client).find_unique(where={"config_type": "cyberark"})
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)
806 return ConfigOverrideSettingsResponse(
807 config_type="cyberark",
808 values=masked_data,
809 field_schema=field_schema,
810 )
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)
815 return ConfigOverrideSettingsResponse(
816 config_type="cyberark",
817 values=masked_env_values,
818 field_schema=field_schema,
819 )
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
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 )
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 )
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
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")
866 _clear_cyberark_state(proxy_config)
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 )
878 return { # mutable-ok: JSON response payload
879 "message": "CyberArk configuration deleted successfully",
880 "status": "success",
881 }
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
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 )
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 )
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 )
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 )
933 return { # mutable-ok: JSON response payload
934 "status": "success",
935 "message": f"Successfully connected to CyberArk Conjur at {client.conjur_addr}",
936 }