351 lines
13 KiB
Python
351 lines
13 KiB
Python
|
|
import base64
|
||
|
|
import os
|
||
|
|
from typing import Any, Dict, Optional, Union
|
||
|
|
from urllib.parse import quote
|
||
|
|
|
||
|
|
import httpx
|
||
|
|
|
||
|
|
import litellm
|
||
|
|
from litellm._logging import verbose_logger
|
||
|
|
from litellm.caching import InMemoryCache
|
||
|
|
from litellm.llms.custom_httpx.http_handler import (
|
||
|
|
_get_httpx_client,
|
||
|
|
get_async_httpx_client,
|
||
|
|
httpxSpecialProvider,
|
||
|
|
)
|
||
|
|
from litellm.proxy._types import KeyManagementSystem
|
||
|
|
|
||
|
|
from .base_secret_manager import BaseSecretManager
|
||
|
|
from .main import str_to_bool
|
||
|
|
|
||
|
|
|
||
|
|
class CyberArkSecretManager(BaseSecretManager):
|
||
|
|
def __init__(self):
|
||
|
|
from litellm.proxy.proxy_server import CommonProxyErrors, premium_user
|
||
|
|
|
||
|
|
# CyberArk Conjur-specific config
|
||
|
|
self.conjur_addr = os.getenv("CYBERARK_API_BASE", "http://127.0.0.1:8080")
|
||
|
|
self.conjur_account = os.getenv("CYBERARK_ACCOUNT", "default")
|
||
|
|
self.conjur_username = os.getenv("CYBERARK_USERNAME", "admin")
|
||
|
|
self.conjur_api_key = os.getenv("CYBERARK_API_KEY", "")
|
||
|
|
|
||
|
|
# Optional config for certificate-based auth
|
||
|
|
self.tls_cert_path = os.getenv("CYBERARK_CLIENT_CERT", "")
|
||
|
|
self.tls_key_path = os.getenv("CYBERARK_CLIENT_KEY", "")
|
||
|
|
|
||
|
|
# SSL verification - can be disabled for self-signed certificates
|
||
|
|
# Set CYBERARK_SSL_VERIFY=false to disable SSL verification
|
||
|
|
ssl_verify_env = str_to_bool(os.getenv("CYBERARK_SSL_VERIFY"))
|
||
|
|
self.ssl_verify: bool = ssl_verify_env if ssl_verify_env is not None else True
|
||
|
|
|
||
|
|
# Validate environment
|
||
|
|
if not self.conjur_api_key and not (self.tls_cert_path and self.tls_key_path):
|
||
|
|
raise ValueError(
|
||
|
|
"Missing CyberArk credentials. Please set CYBERARK_API_KEY or both CYBERARK_CLIENT_CERT and CYBERARK_CLIENT_KEY in your environment."
|
||
|
|
)
|
||
|
|
|
||
|
|
litellm.secret_manager_client = self
|
||
|
|
litellm._key_management_system = KeyManagementSystem.CYBERARK
|
||
|
|
|
||
|
|
# Tokens expire after ~8 minutes, so we cache for 5 minutes to be safe
|
||
|
|
_refresh_interval = int(os.environ.get("CYBERARK_REFRESH_INTERVAL", "300"))
|
||
|
|
self.cache = InMemoryCache(default_ttl=_refresh_interval)
|
||
|
|
|
||
|
|
if premium_user is not True:
|
||
|
|
raise ValueError(
|
||
|
|
f"CyberArk secret manager is only available for premium users. {CommonProxyErrors.not_premium_user.value}"
|
||
|
|
)
|
||
|
|
|
||
|
|
if not self.ssl_verify:
|
||
|
|
verbose_logger.warning(
|
||
|
|
"CyberArk SSL verification is disabled. This is insecure and should only be used for testing with self-signed certificates."
|
||
|
|
)
|
||
|
|
|
||
|
|
def _authenticate(self) -> str:
|
||
|
|
"""
|
||
|
|
Authenticate with CyberArk Conjur and get a session token.
|
||
|
|
|
||
|
|
The token is a JSON object that must be base64-encoded for use in subsequent requests.
|
||
|
|
|
||
|
|
Returns:
|
||
|
|
str: Base64-encoded session token
|
||
|
|
"""
|
||
|
|
# Check if we have a cached token
|
||
|
|
cached_token = self.cache.get_cache("cyberark_auth_token")
|
||
|
|
if cached_token is not None:
|
||
|
|
return cached_token
|
||
|
|
|
||
|
|
verbose_logger.debug("Authenticating with CyberArk Conjur...")
|
||
|
|
auth_url = f"{self.conjur_addr}/authn/{self.conjur_account}/{self.conjur_username}/authenticate"
|
||
|
|
|
||
|
|
try:
|
||
|
|
if self.tls_cert_path and self.tls_key_path:
|
||
|
|
# Certificate-based authentication - need custom client for cert
|
||
|
|
http_client = httpx.Client(
|
||
|
|
cert=(self.tls_cert_path, self.tls_key_path),
|
||
|
|
verify=self.ssl_verify,
|
||
|
|
)
|
||
|
|
resp = http_client.post(auth_url, content=self.conjur_api_key)
|
||
|
|
else:
|
||
|
|
# API key authentication
|
||
|
|
http_handler = _get_httpx_client(params={"ssl_verify": self.ssl_verify})
|
||
|
|
resp = http_handler.client.post(auth_url, content=self.conjur_api_key)
|
||
|
|
|
||
|
|
resp.raise_for_status()
|
||
|
|
|
||
|
|
# The response is a JSON token that needs to be base64-encoded
|
||
|
|
token_json = resp.text
|
||
|
|
token_b64 = base64.b64encode(token_json.encode()).decode()
|
||
|
|
|
||
|
|
verbose_logger.debug("Successfully authenticated with CyberArk Conjur.")
|
||
|
|
|
||
|
|
# Cache the token for the refresh interval
|
||
|
|
self.cache.set_cache(key="cyberark_auth_token", value=token_b64)
|
||
|
|
|
||
|
|
return token_b64
|
||
|
|
except Exception as e:
|
||
|
|
raise RuntimeError(f"Could not authenticate to CyberArk Conjur: {e}")
|
||
|
|
|
||
|
|
def _get_request_headers(self) -> dict:
|
||
|
|
"""
|
||
|
|
Get headers for CyberArk API requests including authentication.
|
||
|
|
|
||
|
|
Returns:
|
||
|
|
dict: Headers with authentication token
|
||
|
|
"""
|
||
|
|
token = self._authenticate()
|
||
|
|
return {"Authorization": f'Token token="{token}"'}
|
||
|
|
|
||
|
|
def _ensure_variable_exists(self, secret_name: str) -> None:
|
||
|
|
"""
|
||
|
|
Ensure a variable exists in CyberArk Conjur by creating a policy entry if needed.
|
||
|
|
|
||
|
|
Args:
|
||
|
|
secret_name: Name of the variable to ensure exists
|
||
|
|
"""
|
||
|
|
# In production, we'd check if the variable exists first
|
||
|
|
# For now, we'll attempt to create it and ignore if it already exists
|
||
|
|
policy_url = f"{self.conjur_addr}/policies/{self.conjur_account}/policy/root"
|
||
|
|
policy_yaml = f"- !variable {secret_name}\n"
|
||
|
|
|
||
|
|
try:
|
||
|
|
client = _get_httpx_client(params={"ssl_verify": self.ssl_verify})
|
||
|
|
resp = client.client.post(
|
||
|
|
policy_url,
|
||
|
|
headers={
|
||
|
|
**self._get_request_headers(),
|
||
|
|
"Content-Type": "application/x-yaml",
|
||
|
|
},
|
||
|
|
content=policy_yaml,
|
||
|
|
)
|
||
|
|
resp.raise_for_status()
|
||
|
|
verbose_logger.debug(f"Created policy entry for variable: {secret_name}")
|
||
|
|
except httpx.HTTPStatusError as e:
|
||
|
|
# Variable might already exist, which is fine
|
||
|
|
if e.response.status_code in [409, 422]:
|
||
|
|
verbose_logger.debug(
|
||
|
|
f"Variable {secret_name} already exists or policy conflict (expected)"
|
||
|
|
)
|
||
|
|
else:
|
||
|
|
verbose_logger.warning(
|
||
|
|
f"Could not ensure variable exists: {e.response.status_code} - {e.response.text}"
|
||
|
|
)
|
||
|
|
except Exception as e:
|
||
|
|
verbose_logger.warning(f"Error ensuring variable exists: {e}")
|
||
|
|
|
||
|
|
def get_url(self, secret_name: str) -> str:
|
||
|
|
"""
|
||
|
|
Build the URL for accessing a secret in CyberArk Conjur.
|
||
|
|
|
||
|
|
Args:
|
||
|
|
secret_name: Name of the secret (will be URL-encoded)
|
||
|
|
|
||
|
|
Returns:
|
||
|
|
str: Full URL for the secret
|
||
|
|
"""
|
||
|
|
# URL-encode the secret name to handle slashes and special characters
|
||
|
|
encoded_name = quote(secret_name, safe="")
|
||
|
|
return (
|
||
|
|
f"{self.conjur_addr}/secrets/{self.conjur_account}/variable/{encoded_name}"
|
||
|
|
)
|
||
|
|
|
||
|
|
async def async_read_secret(
|
||
|
|
self,
|
||
|
|
secret_name: str,
|
||
|
|
optional_params: Optional[dict] = None,
|
||
|
|
timeout: Optional[Union[float, httpx.Timeout]] = None,
|
||
|
|
) -> Optional[str]:
|
||
|
|
"""
|
||
|
|
Reads a secret from CyberArk Conjur using an async HTTPX client.
|
||
|
|
|
||
|
|
Args:
|
||
|
|
secret_name: Name/path of the secret to read
|
||
|
|
optional_params: Additional parameters (not used for Conjur)
|
||
|
|
timeout: Request timeout
|
||
|
|
|
||
|
|
Returns:
|
||
|
|
Optional[str]: The secret value if found, None otherwise
|
||
|
|
"""
|
||
|
|
# Check cache first
|
||
|
|
if self.cache.get_cache(secret_name) is not None:
|
||
|
|
return self.cache.get_cache(secret_name)
|
||
|
|
|
||
|
|
async_client = get_async_httpx_client(
|
||
|
|
llm_provider=httpxSpecialProvider.SecretManager,
|
||
|
|
params={"ssl_verify": self.ssl_verify},
|
||
|
|
)
|
||
|
|
|
||
|
|
try:
|
||
|
|
url = self.get_url(secret_name)
|
||
|
|
response = await async_client.get(url, headers=self._get_request_headers())
|
||
|
|
response.raise_for_status()
|
||
|
|
|
||
|
|
# CyberArk Conjur returns the raw secret value as text
|
||
|
|
secret_value = response.text
|
||
|
|
self.cache.set_cache(secret_name, secret_value)
|
||
|
|
return secret_value
|
||
|
|
|
||
|
|
except httpx.HTTPStatusError as e:
|
||
|
|
if e.response.status_code == 404:
|
||
|
|
verbose_logger.debug(
|
||
|
|
f"Secret {secret_name} not found in CyberArk Conjur"
|
||
|
|
)
|
||
|
|
else:
|
||
|
|
verbose_logger.exception(
|
||
|
|
f"Error reading secret from CyberArk Conjur: {e}"
|
||
|
|
)
|
||
|
|
return None
|
||
|
|
except Exception as e:
|
||
|
|
verbose_logger.exception(f"Error reading secret from CyberArk Conjur: {e}")
|
||
|
|
return None
|
||
|
|
|
||
|
|
def sync_read_secret(
|
||
|
|
self,
|
||
|
|
secret_name: str,
|
||
|
|
optional_params: Optional[dict] = None,
|
||
|
|
timeout: Optional[Union[float, httpx.Timeout]] = None,
|
||
|
|
) -> Optional[str]:
|
||
|
|
"""
|
||
|
|
Reads a secret from CyberArk Conjur using a sync HTTPX client.
|
||
|
|
|
||
|
|
Args:
|
||
|
|
secret_name: Name/path of the secret to read
|
||
|
|
optional_params: Additional parameters (not used for Conjur)
|
||
|
|
timeout: Request timeout
|
||
|
|
|
||
|
|
Returns:
|
||
|
|
Optional[str]: The secret value if found, None otherwise
|
||
|
|
"""
|
||
|
|
# Check cache first
|
||
|
|
if self.cache.get_cache(secret_name) is not None:
|
||
|
|
return self.cache.get_cache(secret_name)
|
||
|
|
|
||
|
|
sync_client = _get_httpx_client(params={"ssl_verify": self.ssl_verify})
|
||
|
|
|
||
|
|
try:
|
||
|
|
url = self.get_url(secret_name)
|
||
|
|
response = sync_client.client.get(url, headers=self._get_request_headers())
|
||
|
|
response.raise_for_status()
|
||
|
|
|
||
|
|
# CyberArk Conjur returns the raw secret value as text
|
||
|
|
secret_value = response.text
|
||
|
|
self.cache.set_cache(secret_name, secret_value)
|
||
|
|
return secret_value
|
||
|
|
|
||
|
|
except httpx.HTTPStatusError as e:
|
||
|
|
if e.response.status_code == 404:
|
||
|
|
verbose_logger.debug(
|
||
|
|
f"Secret {secret_name} not found in CyberArk Conjur"
|
||
|
|
)
|
||
|
|
else:
|
||
|
|
verbose_logger.exception(
|
||
|
|
f"Error reading secret from CyberArk Conjur: {e}"
|
||
|
|
)
|
||
|
|
return None
|
||
|
|
except Exception as e:
|
||
|
|
verbose_logger.exception(f"Error reading secret from CyberArk Conjur: {e}")
|
||
|
|
return None
|
||
|
|
|
||
|
|
async def async_write_secret(
|
||
|
|
self,
|
||
|
|
secret_name: str,
|
||
|
|
secret_value: str,
|
||
|
|
description: Optional[str] = None,
|
||
|
|
optional_params: Optional[dict] = None,
|
||
|
|
timeout: Optional[Union[float, httpx.Timeout]] = None,
|
||
|
|
tags: Optional[Union[dict, list]] = None,
|
||
|
|
) -> Dict[str, Any]:
|
||
|
|
"""
|
||
|
|
Writes a secret to CyberArk Conjur using an async HTTPX client.
|
||
|
|
|
||
|
|
Args:
|
||
|
|
secret_name: Name/path of the secret to write
|
||
|
|
secret_value: Value to store
|
||
|
|
description: Optional description (not used by Conjur)
|
||
|
|
optional_params: Additional parameters
|
||
|
|
timeout: Request timeout
|
||
|
|
tags: Optional tags (not used by Conjur)
|
||
|
|
|
||
|
|
Returns:
|
||
|
|
dict: Response containing status and details of the operation
|
||
|
|
"""
|
||
|
|
async_client = get_async_httpx_client(
|
||
|
|
llm_provider=httpxSpecialProvider.SecretManager,
|
||
|
|
params={"ssl_verify": self.ssl_verify},
|
||
|
|
)
|
||
|
|
|
||
|
|
try:
|
||
|
|
# Ensure the variable exists in the policy first
|
||
|
|
self._ensure_variable_exists(secret_name)
|
||
|
|
|
||
|
|
# Now set the secret value
|
||
|
|
url = self.get_url(secret_name)
|
||
|
|
response = await async_client.post(
|
||
|
|
url=url, headers=self._get_request_headers(), content=secret_value
|
||
|
|
)
|
||
|
|
response.raise_for_status()
|
||
|
|
|
||
|
|
# Update cache
|
||
|
|
self.cache.set_cache(secret_name, secret_value)
|
||
|
|
|
||
|
|
return {
|
||
|
|
"status": "success",
|
||
|
|
"message": f"Secret {secret_name} written successfully",
|
||
|
|
}
|
||
|
|
except Exception as e:
|
||
|
|
verbose_logger.exception(f"Error writing secret to CyberArk Conjur: {e}")
|
||
|
|
return {"status": "error", "message": str(e)}
|
||
|
|
|
||
|
|
async def async_delete_secret(
|
||
|
|
self,
|
||
|
|
secret_name: str,
|
||
|
|
recovery_window_in_days: Optional[int] = 7,
|
||
|
|
optional_params: Optional[dict] = None,
|
||
|
|
timeout: Optional[Union[float, httpx.Timeout]] = None,
|
||
|
|
) -> dict:
|
||
|
|
"""
|
||
|
|
CyberArk Conjur does not support direct secret deletion via API.
|
||
|
|
Secrets can only be removed through policy updates.
|
||
|
|
|
||
|
|
Args:
|
||
|
|
secret_name: Name of the secret
|
||
|
|
recovery_window_in_days: Not used
|
||
|
|
optional_params: Additional parameters
|
||
|
|
timeout: Request timeout
|
||
|
|
|
||
|
|
Returns:
|
||
|
|
dict: Response indicating operation not supported
|
||
|
|
"""
|
||
|
|
verbose_logger.warning(
|
||
|
|
"CyberArk Conjur does not support direct secret deletion. "
|
||
|
|
"Secrets must be removed through policy updates."
|
||
|
|
)
|
||
|
|
|
||
|
|
# Clear from cache
|
||
|
|
self.cache.delete_cache(secret_name)
|
||
|
|
|
||
|
|
return {
|
||
|
|
"status": "not_supported",
|
||
|
|
"message": "CyberArk Conjur does not support direct secret deletion. Use policy updates to remove variables.",
|
||
|
|
}
|