feat(proxy): cyberark conjur secret manager configuration via Admin UI (#38445)

* feat(proxy): CyberArk Conjur secret manager configuration via Admin UI

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* test(ui): mock networking base-url helpers in AdminPanel test

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(proxy): restore deployment CyberArk env config on delete and roll back on persist failure

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(proxy): reinit env-configured hashicorp vault manager after cyberark persist rollback

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

---------

Co-authored-by: yassin <yassin@berri.ai>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
devin-ai-integration[bot] 2026-08-29 13:36:08 -07:00 committed by GitHub
parent 8dd9c4acb1
commit 645792955d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 2195 additions and 16 deletions

View file

@ -24,7 +24,7 @@
"limit": 19
},
"reportExplicitAny": {
"limit": 5486
"limit": 5485
},
"reportFunctionMemberAccess": {
"limit": 7

View file

@ -6459,6 +6459,109 @@
"title": "ConfigOverrideSettingsResponse",
"type": "object"
},
"CyberArkConfig": {
"description": "Configuration for CyberArk Conjur secret manager integration.",
"properties": {
"client_cert": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"description": "Path to the client TLS certificate for certificate-based authentication",
"title": "Client Cert"
},
"client_key": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"description": "Path to the client TLS private key for certificate-based authentication",
"title": "Client Key"
},
"cyberark_account": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"description": "The Conjur organization account name",
"title": "Cyberark Account"
},
"cyberark_api_base": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"description": "The address of the CyberArk Conjur server (e.g., https://conjur.example.com)",
"title": "Cyberark Api Base"
},
"cyberark_api_key": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"description": "API key for Conjur API-key authentication",
"title": "Cyberark Api Key"
},
"cyberark_username": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"description": "The Conjur username (login) to authenticate as",
"title": "Cyberark Username"
},
"refresh_interval": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"description": "Auth token cache TTL in seconds (default: 300)",
"title": "Refresh Interval"
},
"ssl_verify": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"description": "Set to false to disable SSL verification (e.g., for self-signed certificates)",
"title": "Ssl Verify"
}
},
"title": "CyberArkConfig",
"type": "object"
},
"HTTPValidationError": {
"properties": {
"detail": {
@ -6654,6 +6757,192 @@
}
},
"paths": {
"/config_overrides/cyberark": {
"delete": {
"description": "Delete CyberArk Conjur configuration. Idempotent.",
"operationId": "delete_cyberark_config_config_overrides_cyberark_delete",
"parameters": [
{
"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",
"in": "header",
"name": "litellm-changed-by",
"required": false,
"schema": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"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",
"title": "Litellm-Changed-By"
}
}
],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"additionalProperties": {
"type": "string"
},
"title": "Response Delete Cyberark Config Config Overrides Cyberark Delete",
"type": "object"
}
}
},
"description": "Successful Response"
},
"422": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
},
"description": "Validation Error"
}
},
"security": [
{
"APIKeyHeader": []
}
],
"summary": "Delete Cyberark Config",
"tags": [
"config_overrides"
]
},
"get": {
"description": "Get current CyberArk Conjur configuration.\nReturns decrypted values from DB, or falls back to current env vars.\nSensitive fields are masked before leaving the server.",
"operationId": "get_cyberark_config_config_overrides_cyberark_get",
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ConfigOverrideSettingsResponse"
}
}
},
"description": "Successful Response"
}
},
"security": [
{
"APIKeyHeader": []
}
],
"summary": "Get Cyberark Config",
"tags": [
"config_overrides"
]
},
"post": {
"description": "Update CyberArk Conjur secret manager configuration.\nSets environment variables, encrypts sensitive fields, and stores in DB.\nReinitializes the secret manager on this pod.",
"operationId": "update_cyberark_config_config_overrides_cyberark_post",
"parameters": [
{
"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",
"in": "header",
"name": "litellm-changed-by",
"required": false,
"schema": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"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",
"title": "Litellm-Changed-By"
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/CyberArkConfig"
}
}
},
"required": true
},
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"additionalProperties": {
"type": "string"
},
"title": "Response Update Cyberark Config Config Overrides Cyberark Post",
"type": "object"
}
}
},
"description": "Successful Response"
},
"422": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
},
"description": "Validation Error"
}
},
"security": [
{
"APIKeyHeader": []
}
],
"summary": "Update Cyberark Config",
"tags": [
"config_overrides"
]
}
},
"/config_overrides/cyberark/test_connection": {
"post": {
"description": "Test the connection to the currently configured CyberArk Conjur server.\nUses the already-initialized secret manager client. Does not modify any state.",
"operationId": "test_cyberark_connection_config_overrides_cyberark_test_connection_post",
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"additionalProperties": {
"type": "string"
},
"title": "Response Test Cyberark Connection Config Overrides Cyberark Test Connection Post",
"type": "object"
}
}
},
"description": "Successful Response"
}
},
"security": [
{
"APIKeyHeader": []
}
],
"summary": "Test Cyberark Connection",
"tags": [
"config_overrides"
]
}
},
"/config_overrides/hashicorp_vault": {
"delete": {
"description": "Delete Hashicorp Vault configuration. Idempotent.",

View file

@ -3,7 +3,7 @@ import json
import os
from collections.abc import Mapping, Sequence
from datetime import datetime, timezone
from typing import TYPE_CHECKING, Any, Final, Protocol
from typing import TYPE_CHECKING, Final, Protocol
from fastapi import APIRouter, Depends, Header, HTTPException
from pydantic import BaseModel, TypeAdapter
@ -36,10 +36,12 @@ from litellm.repositories.table_repositories import ConfigOverridesRepository
from litellm.types.llms.custom_http import httpxSpecialProvider
from litellm.types.proxy.management_endpoints.config_overrides import (
ConfigOverrideSettingsResponse,
CyberArkConfig,
HashicorpVaultConfig,
)
if TYPE_CHECKING:
from litellm.proxy.proxy_server import ProxyConfig
from litellm.proxy.utils import PrismaClient
router: Final = APIRouter()
@ -83,18 +85,19 @@ def _log_audit_task_exception(task: "asyncio.Task[None]") -> None:
return
exc: Final = task.exception()
if exc is not None:
verbose_proxy_logger.warning("Failed to write hashicorp-vault config audit log: %s", exc)
verbose_proxy_logger.warning("Failed to write config override audit log: %s", exc)
async def _emit_hashicorp_vault_audit_log(
async def _emit_config_override_audit_log(
*,
object_id: str,
action: AUDIT_ACTIONS,
before_config: Mapping[str, object] | None,
after_config: Mapping[str, object] | None,
user_api_key_dict: UserAPIKeyAuth,
litellm_changed_by: str | None,
) -> None:
"""Emit an audit-log row for a /config_overrides/hashicorp_vault mutation.
"""Emit an audit-log row for a /config_overrides/{object_id} mutation.
Mirrors the ``store_audit_logs``-gated pattern from
``team_callback_endpoints.py``. Captured under
@ -118,7 +121,7 @@ async def _emit_hashicorp_vault_audit_log(
changed_by=litellm_changed_by or user_api_key_dict.user_id or litellm_proxy_admin_name,
changed_by_api_key=user_api_key_dict.api_key,
table_name=LitellmTableNames.CONFIG_OVERRIDES_TABLE_NAME,
object_id="hashicorp_vault",
object_id=object_id,
action=action,
updated_values=json.dumps({"config": _redact_config(after_config)}, default=str),
before_value=json.dumps({"config": _redact_config(before_config)}, default=str),
@ -150,6 +153,24 @@ HASHICORP_SENSITIVE_FIELDS: Final[set[str]] = {
"client_key",
}
# --- CyberArk Conjur constants ---
CYBERARK_ENV_VAR_MAPPING: Final[dict[str, str]] = { # mutable-ok: module-level env mapping
"cyberark_api_base": "CYBERARK_API_BASE",
"cyberark_account": "CYBERARK_ACCOUNT",
"cyberark_username": "CYBERARK_USERNAME",
"cyberark_api_key": "CYBERARK_API_KEY",
"client_cert": "CYBERARK_CLIENT_CERT",
"client_key": "CYBERARK_CLIENT_KEY",
"ssl_verify": "CYBERARK_SSL_VERIFY",
"refresh_interval": "CYBERARK_REFRESH_INTERVAL",
}
CYBERARK_SENSITIVE_FIELDS: Final[set[str]] = { # mutable-ok: module-level constant, mirrors HASHICORP_SENSITIVE_FIELDS
"cyberark_api_key",
"client_key",
}
_sensitive_masker: Final = SensitiveDataMasker()
@ -215,9 +236,12 @@ def _parse_config_value(raw: str | Mapping[str, object]) -> dict[str, object]:
return dict(raw)
def _set_env_vars(config_data: Mapping[str, object]) -> None:
"""Set HCP_VAULT_* env vars from config data. Unsets vars for missing/None/empty fields."""
for field_name, env_var_name in HASHICORP_ENV_VAR_MAPPING.items():
def _set_env_vars(
config_data: Mapping[str, object],
env_var_mapping: Mapping[str, str] = HASHICORP_ENV_VAR_MAPPING,
) -> None:
"""Set mapped env vars from config data. Unsets vars for missing/None/empty fields."""
for field_name, env_var_name in env_var_mapping.items():
value = config_data.get(field_name)
if value is not None and value != "":
os.environ[env_var_name] = str(value)
@ -225,13 +249,74 @@ def _set_env_vars(config_data: Mapping[str, object]) -> None:
os.environ.pop(env_var_name, None)
def _clear_hashicorp_vault_state(proxy_config: Any) -> None:
def _clear_hashicorp_vault_state(proxy_config: "ProxyConfig") -> None:
"""Clear all Hashicorp Vault state: env vars, secret manager, and change-detection cache."""
_set_env_vars({})
if litellm._key_management_system == KeyManagementSystem.HASHICORP_VAULT:
litellm.secret_manager_client = None
litellm._key_management_system = None
proxy_config._last_hashicorp_vault_config = None
proxy_config._last_hashicorp_vault_config = None # pyright: ignore[reportPrivateUsage] # proxy-internal change-detection cache
def _snapshot_cyberark_boot_env(proxy_config: "ProxyConfig") -> None:
"""Capture deployment-provided CYBERARK_* env vars once, before the first DB-driven overwrite."""
if proxy_config._cyberark_boot_env is None: # pyright: ignore[reportPrivateUsage] # proxy-internal boot snapshot
proxy_config._cyberark_boot_env = _get_current_env_values(CYBERARK_ENV_VAR_MAPPING) # pyright: ignore[reportPrivateUsage] # proxy-internal boot snapshot
def _restore_cyberark_runtime(proxy_config: "ProxyConfig", env_values: Mapping[str, str | None]) -> None:
"""Restore CYBERARK_* env vars and reinitialize (or drop) the secret manager to match them."""
_set_env_vars(env_values, CYBERARK_ENV_VAR_MAPPING)
if env_values.get("cyberark_api_base"):
try:
proxy_config.initialize_secret_manager(key_management_system="cyberark")
except Exception: # noqa: BLE001 # restore is best-effort; fall through to dropping the manager
verbose_proxy_logger.exception("Failed to restore previous CyberArk configuration")
else:
return
if litellm._key_management_system != KeyManagementSystem.CYBERARK: # pyright: ignore[reportPrivateUsage] # proxy-internal helper, mirrors hashicorp endpoint usage
return
litellm.secret_manager_client = None
litellm._key_management_system = None # pyright: ignore[reportPrivateUsage] # proxy-internal helper, mirrors hashicorp endpoint usage
# Force the vault reload to re-init from its own row so no manager is stranded inactive
proxy_config._last_hashicorp_vault_config = None # pyright: ignore[reportPrivateUsage] # proxy-internal change-detection cache
if os.environ.get("HCP_VAULT_ADDR"):
try:
proxy_config.initialize_secret_manager(key_management_system="hashicorp_vault")
except Exception: # noqa: BLE001 # restore is best-effort; the vault reload loop retries from its own row
verbose_proxy_logger.exception("Failed to reinitialize Hashicorp Vault after CyberArk rollback")
def _clear_cyberark_state(proxy_config: "ProxyConfig") -> None:
"""Drop DB-driven CyberArk state, restoring deployment-provided env vars if any."""
boot_env: Final[Mapping[str, str | None]] = (
proxy_config._cyberark_boot_env or {} # pyright: ignore[reportPrivateUsage] # proxy-internal boot snapshot
)
_restore_cyberark_runtime(proxy_config, boot_env)
proxy_config._last_cyberark_config = None # pyright: ignore[reportPrivateUsage] # proxy-internal helper, mirrors hashicorp endpoint usage
async def _persist_cyberark_config(
prisma_client: "PrismaClient",
proxy_config: "ProxyConfig",
config_data: Mapping[str, object],
) -> dict[str, object]:
"""Encrypt and upsert the CyberArk config row; returns the stored (encrypted) payload."""
encrypted_data: Final = proxy_config._encrypt_env_variables(dict(config_data)) # pyright: ignore[reportPrivateUsage] # proxy-internal helper, mirrors hashicorp endpoint usage
config_value: Final = safe_dumps(encrypted_data)
await _config_overrides_table(prisma_client).upsert(
where={"config_type": "cyberark"}, # mutable-ok: prisma upsert payload
data={ # mutable-ok: prisma upsert payload
"create": { # mutable-ok: prisma upsert payload
"config_type": "cyberark",
"config_value": config_value,
},
"update": { # mutable-ok: prisma upsert payload
"config_value": config_value,
},
},
)
return safe_json_loads(config_value)
# --- Hashicorp Vault endpoints ---
@ -358,7 +443,8 @@ async def update_hashicorp_vault_config(
# row was absent or its ``config_value`` was NULL.
before_config: Final = existing_decrypted if existing_decrypted is not None else env_values
action: Final[AUDIT_ACTIONS] = "updated" if existing_record is not None else "created"
await _emit_hashicorp_vault_audit_log(
await _emit_config_override_audit_log(
object_id="hashicorp_vault",
action=action,
before_config=before_config,
after_config=config_data,
@ -484,7 +570,8 @@ async def delete_hashicorp_vault_config(
# Only emit audit log if a row was actually removed; an idempotent
# delete on a non-existent row produces no security-relevant change.
if deleted:
await _emit_hashicorp_vault_audit_log(
await _emit_config_override_audit_log(
object_id="hashicorp_vault",
action="deleted",
before_config=before_config,
after_config=None,
@ -529,7 +616,7 @@ async def test_hashicorp_vault_connection(
# Step 1: Authenticate (exercises AppRole login, TLS cert login, or direct token)
try:
headers: Final[dict[str, str]] = await asyncio.to_thread(client._get_request_headers)
headers: Final[Mapping[str, str]] = await asyncio.to_thread(client._get_request_headers)
except Exception as e:
raise HTTPException(
status_code=502,
@ -554,3 +641,298 @@ async def test_hashicorp_vault_connection(
"status": "success",
"message": f"Successfully connected to Vault at {client.vault_addr}",
}
# --- CyberArk Conjur endpoints ---
@router.post(
"/config_overrides/cyberark",
tags=["Config Overrides"], # mutable-ok: FastAPI route decorator metadata
dependencies=[Depends(user_api_key_auth)], # mutable-ok: FastAPI route decorator metadata
)
async def update_cyberark_config(
config: CyberArkConfig,
user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), # noqa: B008 # FastAPI dependency injection
litellm_changed_by: str | None = Header(
None,
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",
),
) -> dict[str, str]:
"""
Update CyberArk Conjur secret manager configuration.
Sets environment variables, encrypts sensitive fields, and stores in DB.
Reinitializes the secret manager on this pod.
"""
from litellm.proxy.proxy_server import prisma_client, proxy_config
if user_api_key_dict.user_role != LitellmUserRoles.PROXY_ADMIN:
raise HTTPException(
status_code=403,
detail="Only admin users can update config overrides",
)
if prisma_client is None:
raise HTTPException(
status_code=500,
detail=CommonProxyErrors.db_not_connected_error.value,
)
config_data: dict[str, object] = config.model_dump(exclude_none=True) # mutable-ok: merged # rebind-ok: stripped
# Merge ALL fields the user didn't send: try DB first, fall back to env vars.
# Omitted field = keep existing; empty string = clear/remove the field.
existing_record: Final = await _config_overrides_table(prisma_client).find_unique(
where={"config_type": "cyberark"} # mutable-ok: prisma where clause
)
existing_decrypted: dict[str, object] | None = None # mutable-ok: DB payload # rebind-ok: set when record exists
env_values: dict[str, str | None] = {} # mutable-ok: env snapshot # rebind-ok: populated when no DB record exists
if existing_record is not None and existing_record.config_value is not None:
existing_data: Final = _parse_config_value(existing_record.config_value)
existing_decrypted = proxy_config._decrypt_db_variables(existing_data) # pyright: ignore[reportPrivateUsage] # rebind-ok: populated when a prior record decrypts
for field in CYBERARK_ENV_VAR_MAPPING:
if field not in config_data and existing_decrypted.get(field):
config_data[field] = existing_decrypted[field]
else:
env_values = _get_current_env_values(CYBERARK_ENV_VAR_MAPPING) # rebind-ok: populated when no DB record exists
for field in CYBERARK_ENV_VAR_MAPPING:
if field not in config_data and env_values.get(field):
config_data[field] = env_values[field]
config_data = {k: v for k, v in config_data.items() if v != ""} # mutable-ok: dict # rebind-ok: "" means clear
has_api_base: Final = bool(config_data.get("cyberark_api_base"))
has_api_key_auth: Final = bool(config_data.get("cyberark_api_key"))
has_tls_cert_auth: Final = bool(config_data.get("client_cert") and config_data.get("client_key"))
if not has_api_base:
raise HTTPException(
status_code=400,
detail="CyberArk API Base is required",
)
if not has_api_key_auth and not has_tls_cert_auth:
raise HTTPException(
status_code=400,
detail="At least one authentication method is required: "
"provide an API Key, or both Client Certificate and Client Key",
)
_snapshot_cyberark_boot_env(proxy_config)
previous_env: Final = _get_current_env_values(CYBERARK_ENV_VAR_MAPPING)
_set_env_vars(config_data, CYBERARK_ENV_VAR_MAPPING)
try:
proxy_config.initialize_secret_manager(key_management_system="cyberark")
except Exception as e: # noqa: BLE001 # any init failure must roll back env vars
_set_env_vars(previous_env, CYBERARK_ENV_VAR_MAPPING)
verbose_proxy_logger.exception("Error reinitializing CyberArk secret manager: %s", str(e))
raise HTTPException(
status_code=500,
detail=f"Failed to initialize secret manager: {e}",
)
try:
proxy_config._last_cyberark_config = await _persist_cyberark_config( # pyright: ignore[reportPrivateUsage] # proxy-internal helper, mirrors hashicorp endpoint usage
prisma_client, proxy_config, config_data
)
except Exception as e: # noqa: BLE001 # persistence failure must roll back the runtime state set above
_restore_cyberark_runtime(proxy_config, previous_env)
verbose_proxy_logger.exception("Error persisting CyberArk configuration: %s", str(e))
raise HTTPException(
status_code=500,
detail=f"Failed to persist CyberArk configuration: {e}",
)
before_config: Final = existing_decrypted if existing_decrypted is not None else env_values
action: Final[AUDIT_ACTIONS] = "updated" if existing_record is not None else "created"
await _emit_config_override_audit_log(
object_id="cyberark",
action=action,
before_config=before_config,
after_config=config_data,
user_api_key_dict=user_api_key_dict,
litellm_changed_by=litellm_changed_by,
)
return { # mutable-ok: JSON response payload
"message": "CyberArk configuration updated successfully",
"status": "success",
}
@router.get(
"/config_overrides/cyberark",
tags=["Config Overrides"], # mutable-ok: FastAPI route decorator metadata
dependencies=[Depends(user_api_key_auth)], # mutable-ok: FastAPI route decorator metadata
response_model=ConfigOverrideSettingsResponse,
)
async def get_cyberark_config(
user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), # noqa: B008 # FastAPI dependency injection
) -> ConfigOverrideSettingsResponse:
"""
Get current CyberArk Conjur configuration.
Returns decrypted values from DB, or falls back to current env vars.
Sensitive fields are masked before leaving the server.
"""
from litellm.proxy.management_endpoints.common_utils import (
_user_has_admin_view, # pyright: ignore[reportPrivateUsage] # proxy-internal helper, mirrors hashicorp endpoint usage
)
from litellm.proxy.proxy_server import prisma_client, proxy_config
if not _user_has_admin_view(user_api_key_dict):
raise HTTPException(
status_code=403,
detail="Only admin users can view config overrides",
)
if prisma_client is None:
raise HTTPException(
status_code=500,
detail=CommonProxyErrors.db_not_connected_error.value,
)
field_schema: Final = _build_field_schema(CyberArkConfig)
db_record: Final = await _config_overrides_table(prisma_client).find_unique(
where={"config_type": "cyberark"}
) # mutable-ok: prisma where clause
if db_record is not None and db_record.config_value is not None:
config_data: Final = _parse_config_value(db_record.config_value)
decrypted_data: Final[Mapping[str, object]] = proxy_config._decrypt_db_variables(config_data) # pyright: ignore[reportPrivateUsage] # proxy-internal helper, mirrors hashicorp endpoint usage
masked_data: Final = _mask_sensitive_fields(decrypted_data, CYBERARK_SENSITIVE_FIELDS)
return ConfigOverrideSettingsResponse(
config_type="cyberark",
values=masked_data,
field_schema=field_schema,
)
env_values: Final = _get_current_env_values(CYBERARK_ENV_VAR_MAPPING)
masked_env_values: Final = _mask_sensitive_fields(env_values, CYBERARK_SENSITIVE_FIELDS)
return ConfigOverrideSettingsResponse(
config_type="cyberark",
values=masked_env_values,
field_schema=field_schema,
)
@router.delete(
"/config_overrides/cyberark",
tags=["Config Overrides"], # mutable-ok: FastAPI route decorator metadata
dependencies=[Depends(user_api_key_auth)], # mutable-ok: FastAPI route decorator metadata
)
async def delete_cyberark_config(
user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), # noqa: B008 # FastAPI dependency injection
litellm_changed_by: str | None = Header(
None,
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",
),
) -> dict[str, str]:
"""Delete CyberArk Conjur configuration. Idempotent."""
from litellm.proxy.proxy_server import prisma_client, proxy_config
if user_api_key_dict.user_role != LitellmUserRoles.PROXY_ADMIN:
raise HTTPException(
status_code=403,
detail="Only admin users can delete config overrides",
)
if prisma_client is None:
raise HTTPException(
status_code=500,
detail=CommonProxyErrors.db_not_connected_error.value,
)
existing_record: Final = await _config_overrides_table(prisma_client).find_unique(
where={"config_type": "cyberark"} # mutable-ok: prisma where clause
)
before_config: dict[str, object] | None = None # mutable-ok: audit snapshot # rebind-ok: set when decrypts
if existing_record is not None and existing_record.config_value is not None:
try:
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
except Exception: # noqa: BLE001 # undecryptable prior config must not block deletion
before_config = None # rebind-ok: reset when decryption fails
deleted = False # rebind-ok: set true once the DB row is removed
try:
await _config_overrides_table(prisma_client).delete(
where={"config_type": "cyberark"}
) # mutable-ok: prisma where clause
deleted = True # rebind-ok: set true once the DB row is removed
except RecordNotFoundError:
verbose_proxy_logger.debug("No existing CyberArk config record to delete")
_clear_cyberark_state(proxy_config)
if deleted:
await _emit_config_override_audit_log(
object_id="cyberark",
action="deleted",
before_config=before_config,
after_config=None,
user_api_key_dict=user_api_key_dict,
litellm_changed_by=litellm_changed_by,
)
return { # mutable-ok: JSON response payload
"message": "CyberArk configuration deleted successfully",
"status": "success",
}
@router.post(
"/config_overrides/cyberark/test_connection",
tags=["Config Overrides"], # mutable-ok: FastAPI route decorator metadata
dependencies=[Depends(user_api_key_auth)], # mutable-ok: FastAPI route decorator metadata
)
async def test_cyberark_connection(
user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), # noqa: B008 # FastAPI dependency injection
) -> dict[str, str]:
"""
Test the connection to the currently configured CyberArk Conjur server.
Uses the already-initialized secret manager client. Does not modify any state.
"""
from litellm.secret_managers.cyberark_secret_manager import CyberArkSecretManager
if user_api_key_dict.user_role != LitellmUserRoles.PROXY_ADMIN:
raise HTTPException(
status_code=403,
detail="Only admin users can test CyberArk connection",
)
client: Final = litellm.secret_manager_client
if not isinstance(client, CyberArkSecretManager):
raise HTTPException(
status_code=400,
detail="CyberArk is not configured. Save a configuration first.",
)
try:
headers: Final[Mapping[str, str]] = await asyncio.to_thread(client._get_request_headers) # pyright: ignore[reportPrivateUsage] # proxy-internal helper, mirrors hashicorp endpoint usage
except Exception as e: # noqa: BLE001 # surface any auth failure as a 502 with detail
raise HTTPException(
status_code=502,
detail=f"CyberArk authentication failed: {e}",
)
try:
async_client: Final = get_async_httpx_client(
llm_provider=httpxSpecialProvider.SecretManager,
params={"ssl_verify": client.ssl_verify}, # mutable-ok: httpx client params
)
whoami_url: Final = f"{client.conjur_addr}/whoami"
response: Final = await async_client.get(whoami_url, headers=headers)
response.raise_for_status()
except Exception as e: # noqa: BLE001 # surface any connectivity/TLS failure as a 502 with detail
raise HTTPException(
status_code=502,
detail=f"CyberArk token validation failed: {e}",
)
return { # mutable-ok: JSON response payload
"status": "success",
"message": f"Successfully connected to CyberArk Conjur at {client.conjur_addr}",
}

View file

@ -4281,6 +4281,8 @@ class ProxyConfig:
self.config: dict[str, Any] = {}
self._last_semantic_filter_config: dict[str, object] | None = None
self._last_hashicorp_vault_config: dict[str, object] | None = None
self._last_cyberark_config: dict[str, object] | None = None # mutable-ok: change-detection cache
self._cyberark_boot_env: dict[str, str | None] | None = None # mutable-ok: deployment env snapshot, set once
self.worker_registry: list[WorkerRegistryEntry] = []
self.config_sync_subscriber: ConfigSyncSubscriber | None = None
self.auth_cache_invalidation_subscriber: AuthCacheInvalidationSubscriber | None = None
@ -6977,6 +6979,7 @@ class ProxyConfig:
if self._should_load_db_object(object_type="config_overrides"):
await self._init_hashicorp_vault_config_override(prisma_client=prisma_client)
await self._init_cyberark_config_override(prisma_client=prisma_client)
await self._apply_safe_litellm_settings_overrides_from_db(prisma_client=prisma_client)
@ -7141,6 +7144,64 @@ class ProxyConfig:
str(e),
)
async def _init_cyberark_config_override(self, prisma_client: PrismaClient) -> None:
"""
Load CyberArk Conjur config override from DB.
Decrypts sensitive fields, sets CYBERARK_* env vars, and reinitializes the secret manager.
Called periodically via _init_non_llm_objects_in_db to sync config across pods.
"""
from litellm.proxy.management_endpoints.config_override_endpoints import (
CYBERARK_ENV_VAR_MAPPING,
_clear_cyberark_state, # pyright: ignore[reportPrivateUsage] # module-internal helper shared with the endpoint module
_get_current_env_values, # pyright: ignore[reportPrivateUsage] # module-internal helper shared with the endpoint module
_parse_config_value, # pyright: ignore[reportPrivateUsage] # module-internal helper shared with the endpoint module
_set_env_vars, # pyright: ignore[reportPrivateUsage] # module-internal helper shared with the endpoint module
_snapshot_cyberark_boot_env, # pyright: ignore[reportPrivateUsage] # module-internal helper shared with the endpoint module
)
try:
db_record: Final[_ConfigOverridesRow | None] = cast( # cast-ok: prisma Json stub is `str`, runtime dict
"_ConfigOverridesRow | None",
await call_with_db_reconnect_retry(
prisma_client,
lambda: ConfigOverridesRepository(prisma_client).table.find_unique(
where={"config_type": "cyberark"} # mutable-ok: prisma where clause
),
reason="init_cyberark_config_override_lookup_failure",
),
)
if db_record is None or db_record.config_value is None:
if self._last_cyberark_config is not None:
_clear_cyberark_state(self)
return
config_data: Final = _parse_config_value(db_record.config_value)
# Skip reinit if config hasn't changed since last poll
if self._last_cyberark_config == config_data:
return
decrypted_data: Final = self._decrypt_db_variables(config_data)
_snapshot_cyberark_boot_env(self)
previous_env: Final = _get_current_env_values(CYBERARK_ENV_VAR_MAPPING)
_set_env_vars(decrypted_data, CYBERARK_ENV_VAR_MAPPING)
try:
self.initialize_secret_manager(key_management_system="cyberark")
except Exception:
_set_env_vars(previous_env, CYBERARK_ENV_VAR_MAPPING)
raise
self._last_cyberark_config = config_data.copy()
verbose_proxy_logger.debug("CyberArk config override loaded from DB")
except Exception as e: # noqa: BLE001 # any DB/decrypt/init failure must not break proxy boot
verbose_proxy_logger.exception(
"Error loading CyberArk config override from DB: %s",
str(e),
)
async def check_periodic_reloads(self, prisma_client: PrismaClient):
"""
Run the admin-configured periodic model cost map reload.

View file

@ -52,6 +52,43 @@ class HashicorpVaultConfig(BaseModel):
)
class CyberArkConfig(BaseModel):
"""Configuration for CyberArk Conjur secret manager integration."""
cyberark_api_base: str | None = Field(
default=None,
description="The address of the CyberArk Conjur server (e.g., https://conjur.example.com)",
)
cyberark_account: str | None = Field(
default=None,
description="The Conjur organization account name",
)
cyberark_username: str | None = Field(
default=None,
description="The Conjur username (login) to authenticate as",
)
cyberark_api_key: str | None = Field(
default=None,
description="API key for Conjur API-key authentication",
)
client_cert: str | None = Field(
default=None,
description="Path to the client TLS certificate for certificate-based authentication",
)
client_key: str | None = Field(
default=None,
description="Path to the client TLS private key for certificate-based authentication",
)
ssl_verify: str | None = Field(
default=None,
description="Set to false to disable SSL verification (e.g., for self-signed certificates)",
)
refresh_interval: str | None = Field(
default=None,
description="Auth token cache TTL in seconds (default: 300)",
)
class ConfigOverrideSettingsResponse(BaseModel):
"""Response model for config override settings GET endpoints."""

View file

@ -24,7 +24,7 @@
"limit": 133
},
"ANN401": {
"limit": 655
"limit": 654
},
"ASYNC230": {
"limit": 11
@ -231,7 +231,7 @@
"limit": 5
},
"TID251": {
"limit": 1117
"limit": 1116
},
"TRY002": {
"limit": 524

View file

@ -11,16 +11,19 @@ import litellm
import litellm.proxy.proxy_server as ps
from litellm.proxy._types import KeyManagementSystem, LitellmUserRoles, UserAPIKeyAuth
from litellm.proxy.management_endpoints.config_override_endpoints import (
CYBERARK_ENV_VAR_MAPPING,
HASHICORP_ENV_VAR_MAPPING,
_build_field_schema,
_set_env_vars,
)
from litellm.proxy.proxy_server import app
from litellm.types.proxy.management_endpoints.config_overrides import (
CyberArkConfig,
HashicorpVaultConfig,
)
VAULT_URL = "/config_overrides/hashicorp_vault"
CYBERARK_URL = "/config_overrides/cyberark"
@pytest.fixture
@ -42,6 +45,7 @@ def _make_mock_proxy_config():
cfg = MagicMock()
cfg.initialize_secret_manager = MagicMock()
cfg._last_hashicorp_vault_config = None
cfg._cyberark_boot_env = None
cfg._encrypt_env_variables = MagicMock(
side_effect=lambda d: {k: f"enc_{v}" for k, v in d.items()}
)
@ -67,6 +71,8 @@ def _cleanup():
app.dependency_overrides.pop(ps.user_api_key_auth, None)
for env_var in HASHICORP_ENV_VAR_MAPPING.values():
os.environ.pop(env_var, None)
for env_var in CYBERARK_ENV_VAR_MAPPING.values():
os.environ.pop(env_var, None)
def _set_admin():
@ -275,6 +281,391 @@ async def test_hashicorp_vault_validation_errors_and_access_control(
_cleanup()
@pytest.mark.asyncio
async def test_cyberark_crud_lifecycle(client, monkeypatch):
"""Create → read (masked) → partial update (merge from DB) → clear field →
delete idempotent delete env fallback merge from env schema."""
mock_prisma, mock_db = _make_mock_db()
mock_cfg = _make_mock_proxy_config()
mock_cfg._last_cyberark_config = None
monkeypatch.setattr(ps, "prisma_client", mock_prisma)
monkeypatch.setattr(ps, "proxy_config", mock_cfg)
old_client, old_kms = litellm.secret_manager_client, litellm._key_management_system
_set_admin()
try:
# 1. POST: create with API-key auth
r = client.post(
CYBERARK_URL,
json={
"cyberark_api_base": "https://conjur.example.com",
"cyberark_account": "myorg",
"cyberark_username": "litellm-user",
"cyberark_api_key": "my-secret-api-key",
},
)
assert r.status_code == 200
assert os.environ["CYBERARK_API_BASE"] == "https://conjur.example.com"
assert os.environ["CYBERARK_API_KEY"] == "my-secret-api-key"
data = _upserted_data(mock_db)
assert data["cyberark_api_key"] == "enc_my-secret-api-key"
mock_cfg.initialize_secret_manager.assert_called_with(
key_management_system="cyberark"
)
assert mock_cfg._last_cyberark_config is not None
# 2. GET: sensitive fields masked
mock_db.find_unique = AsyncMock(return_value=_db_record(data))
r = client.get(CYBERARK_URL)
assert r.status_code == 200
vals = r.json()["values"]
assert vals["cyberark_api_base"] == "https://conjur.example.com"
assert "*" in vals["cyberark_api_key"]
assert "properties" in r.json()["field_schema"]
# 3. POST partial: omitted fields merge from DB
r = client.post(CYBERARK_URL, json={"cyberark_api_base": "https://conjur.new.com"})
assert r.status_code == 200
data = _upserted_data(mock_db)
assert data["cyberark_api_base"] == "enc_https://conjur.new.com"
assert data["cyberark_api_key"] == "enc_my-secret-api-key"
assert data["cyberark_account"] == "enc_myorg"
# 4. POST empty string: clears field, switches to cert auth
step3 = {
**data,
"client_cert": "enc_/certs/client.pem",
"client_key": "enc_/certs/client.key",
}
mock_db.find_unique = AsyncMock(return_value=_db_record(step3))
mock_db.upsert = AsyncMock(return_value=None)
r = client.post(CYBERARK_URL, json={"cyberark_api_key": ""})
assert r.status_code == 200
data = _upserted_data(mock_db)
assert "cyberark_api_key" not in data
assert data["client_cert"] == "enc_/certs/client.pem"
# 5. DELETE: clears everything
litellm.secret_manager_client = MagicMock() # test-quality-ok: endpoint hot-reloads litellm globals; test must set and restore them
litellm._key_management_system = KeyManagementSystem.CYBERARK # test-quality-ok: endpoint hot-reloads litellm globals; test must set and restore them
r = client.delete(CYBERARK_URL)
assert r.status_code == 200
assert os.environ.get("CYBERARK_API_BASE") is None
assert litellm.secret_manager_client is None
assert mock_cfg._last_cyberark_config is None
# 6. DELETE idempotent
mock_db.delete = AsyncMock(
side_effect=RecordNotFoundError(
data={"clientVersion": "0.0.0"}, message="Not found"
)
)
assert client.delete(CYBERARK_URL).status_code == 200
# 7. GET: env var fallback with masking
mock_db.find_unique = AsyncMock(return_value=None)
monkeypatch.setenv("CYBERARK_API_BASE", "https://conjur.env.com")
monkeypatch.setenv("CYBERARK_API_KEY", "env-api-key")
r = client.get(CYBERARK_URL)
vals = r.json()["values"]
assert vals["cyberark_api_base"] == "https://conjur.env.com"
assert "*" in vals["cyberark_api_key"]
# 8. POST: merge from env vars
mock_cfg.initialize_secret_manager = MagicMock()
mock_db.upsert = AsyncMock(return_value=None)
r = client.post(CYBERARK_URL, json={"cyberark_api_base": "https://conjur.merged.com"})
assert r.status_code == 200
data = _upserted_data(mock_db)
assert data["cyberark_api_key"] == "enc_env-api-key"
# 9. _build_field_schema
schema = _build_field_schema(CyberArkConfig)
assert "cyberark_api_base" in schema["properties"]
assert len(schema["properties"]["cyberark_api_base"]["description"]) > 0
finally:
litellm.secret_manager_client = old_client # test-quality-ok: endpoint hot-reloads litellm globals; test must set and restore them
litellm._key_management_system = old_kms # test-quality-ok: endpoint hot-reloads litellm globals; test must set and restore them
_cleanup()
@pytest.mark.asyncio
async def test_cyberark_validation_errors_and_access_control(client, monkeypatch):
"""Validation (missing api base, missing auth, init failure rollback),
DELETE preserves non-CyberArk secret managers, non-admin 403."""
mock_prisma, mock_db = _make_mock_db()
mock_cfg = MagicMock()
mock_cfg._last_cyberark_config = {"cyberark_api_base": "old"}
mock_cfg._cyberark_boot_env = None
monkeypatch.setattr(ps, "prisma_client", mock_prisma)
monkeypatch.setattr(ps, "proxy_config", mock_cfg)
old_client, old_kms = litellm.secret_manager_client, litellm._key_management_system
_set_admin()
try:
# 1. Missing cyberark_api_base → 400
r = client.post(CYBERARK_URL, json={"cyberark_api_key": "key"})
assert r.status_code == 400
assert "API Base" in r.json()["detail"]
# 2. Missing auth → 400 (cert without key is not valid auth)
r = client.post(
CYBERARK_URL,
json={"cyberark_api_base": "https://c.com", "client_cert": "/c.pem"},
)
assert r.status_code == 400
assert "authentication" in r.json()["detail"].lower()
# 3. Init failure → 500, env vars restored, nothing persisted
mock_cfg.initialize_secret_manager = MagicMock(side_effect=Exception("fail"))
monkeypatch.setenv("CYBERARK_API_BASE", "https://conjur.old.com")
monkeypatch.setenv("CYBERARK_API_KEY", "old-key")
r = client.post(
CYBERARK_URL,
json={"cyberark_api_base": "https://bad.com", "cyberark_api_key": "bad"},
)
assert r.status_code == 500
assert os.environ["CYBERARK_API_BASE"] == "https://conjur.old.com"
mock_db.upsert.assert_not_awaited()
# 4. DELETE preserves non-CyberArk secret manager
aws = MagicMock()
litellm.secret_manager_client = aws # test-quality-ok: endpoint hot-reloads litellm globals; test must set and restore them
litellm._key_management_system = KeyManagementSystem.AWS_SECRET_MANAGER # test-quality-ok: endpoint hot-reloads litellm globals; test must set and restore them
assert client.delete(CYBERARK_URL).status_code == 200
assert litellm.secret_manager_client is aws
# 5. Non-admin → 403
app.dependency_overrides[ps.user_api_key_auth] = lambda: UserAPIKeyAuth(
user_role=LitellmUserRoles.INTERNAL_USER, user_id="user"
)
assert client.get(CYBERARK_URL).status_code == 403
assert (
client.post(
CYBERARK_URL, json={"cyberark_api_base": "https://c.com"}
).status_code
== 403
)
assert client.delete(CYBERARK_URL).status_code == 403
assert client.post(CYBERARK_URL + "/test_connection").status_code == 403
finally:
litellm.secret_manager_client = old_client # test-quality-ok: endpoint hot-reloads litellm globals; test must set and restore them
litellm._key_management_system = old_kms # test-quality-ok: endpoint hot-reloads litellm globals; test must set and restore them
_cleanup()
@pytest.mark.asyncio
async def test_cyberark_delete_restores_deployment_env_config(client, monkeypatch):
"""Deleting the DB override must restore env vars the deployment started with,
and reinitialize the manager from them, instead of wiping CyberArk entirely."""
mock_prisma, mock_db = _make_mock_db()
mock_cfg = _make_mock_proxy_config()
mock_cfg._last_cyberark_config = None
monkeypatch.setattr(ps, "prisma_client", mock_prisma)
monkeypatch.setattr(ps, "proxy_config", mock_cfg)
old_client, old_kms = litellm.secret_manager_client, litellm._key_management_system
_set_admin()
try:
monkeypatch.setenv("CYBERARK_API_BASE", "https://conjur.boot.com")
monkeypatch.setenv("CYBERARK_API_KEY", "boot-key")
r = client.post(
CYBERARK_URL,
json={"cyberark_api_base": "https://conjur.db.com", "cyberark_api_key": "db-key"},
)
assert r.status_code == 200
assert os.environ["CYBERARK_API_BASE"] == "https://conjur.db.com"
mock_cfg.initialize_secret_manager.reset_mock()
r = client.delete(CYBERARK_URL)
assert r.status_code == 200
assert os.environ["CYBERARK_API_BASE"] == "https://conjur.boot.com"
assert os.environ["CYBERARK_API_KEY"] == "boot-key"
mock_cfg.initialize_secret_manager.assert_called_with(key_management_system="cyberark")
assert mock_cfg._last_cyberark_config is None
finally:
litellm.secret_manager_client = old_client # test-quality-ok: endpoint hot-reloads litellm globals; test must set and restore them
litellm._key_management_system = old_kms # test-quality-ok: endpoint hot-reloads litellm globals; test must set and restore them
_cleanup()
@pytest.mark.asyncio
async def test_cyberark_persist_failure_rolls_back_runtime_state(client, monkeypatch):
"""If the DB upsert fails after the manager was reinitialized, the endpoint
must restore the previous env vars and reinitialize from them, so this pod
does not keep serving credentials that were never committed to the DB."""
mock_prisma, mock_db = _make_mock_db()
mock_cfg = _make_mock_proxy_config()
mock_cfg._last_cyberark_config = None
mock_db.upsert = AsyncMock(side_effect=Exception("db write failed"))
monkeypatch.setattr(ps, "prisma_client", mock_prisma)
monkeypatch.setattr(ps, "proxy_config", mock_cfg)
old_client, old_kms = litellm.secret_manager_client, litellm._key_management_system
_set_admin()
try:
monkeypatch.setenv("CYBERARK_API_BASE", "https://conjur.prev.com")
monkeypatch.setenv("CYBERARK_API_KEY", "prev-key")
r = client.post(
CYBERARK_URL,
json={"cyberark_api_base": "https://conjur.new.com", "cyberark_api_key": "new-key"},
)
assert r.status_code == 500
assert "persist" in r.json()["detail"].lower()
assert os.environ["CYBERARK_API_BASE"] == "https://conjur.prev.com"
assert os.environ["CYBERARK_API_KEY"] == "prev-key"
# last call must be the rollback reinit against the restored env
assert (
mock_cfg.initialize_secret_manager.call_args_list[-1].kwargs["key_management_system"] == "cyberark"
)
assert os.environ.get("CYBERARK_API_BASE") != "https://conjur.new.com"
finally:
litellm.secret_manager_client = old_client # test-quality-ok: endpoint hot-reloads litellm globals; test must set and restore them
litellm._key_management_system = old_kms # test-quality-ok: endpoint hot-reloads litellm globals; test must set and restore them
_cleanup()
@pytest.mark.asyncio
async def test_cyberark_persist_failure_restores_hashicorp_manager(client, monkeypatch):
"""If CyberArk init displaced an env-configured Hashicorp manager and the DB
upsert then fails, rollback must bring the Hashicorp manager back."""
mock_prisma, mock_db = _make_mock_db()
mock_cfg = _make_mock_proxy_config()
mock_cfg._last_cyberark_config = None
mock_db.upsert = AsyncMock(side_effect=Exception("db write failed"))
def _fake_init(key_management_system):
litellm._key_management_system = ( # test-quality-ok: endpoint hot-reloads litellm globals; test must set and restore them
KeyManagementSystem.CYBERARK
if key_management_system == "cyberark"
else KeyManagementSystem.HASHICORP_VAULT
)
mock_cfg.initialize_secret_manager = MagicMock(side_effect=_fake_init)
monkeypatch.setattr(ps, "prisma_client", mock_prisma)
monkeypatch.setattr(ps, "proxy_config", mock_cfg)
old_client, old_kms = litellm.secret_manager_client, litellm._key_management_system
_set_admin()
try:
monkeypatch.setenv("HCP_VAULT_ADDR", "https://vault.example.com")
litellm._key_management_system = KeyManagementSystem.HASHICORP_VAULT # test-quality-ok: endpoint hot-reloads litellm globals; test must set and restore them
r = client.post(
CYBERARK_URL,
json={"cyberark_api_base": "https://conjur.new.com", "cyberark_api_key": "new-key"},
)
assert r.status_code == 500
assert litellm._key_management_system == KeyManagementSystem.HASHICORP_VAULT
assert (
mock_cfg.initialize_secret_manager.call_args_list[-1].kwargs["key_management_system"] == "hashicorp_vault"
)
finally:
litellm.secret_manager_client = old_client # test-quality-ok: endpoint hot-reloads litellm globals; test must set and restore them
litellm._key_management_system = old_kms # test-quality-ok: endpoint hot-reloads litellm globals; test must set and restore them
os.environ.pop("HCP_VAULT_ADDR", None)
_cleanup()
@pytest.mark.asyncio
async def test_cyberark_audit_log_redacts_values(client, monkeypatch):
monkeypatch.setattr(litellm, "store_audit_logs", True)
mock_prisma, mock_db = _make_mock_db()
mock_cfg = _make_mock_proxy_config()
mock_cfg._last_cyberark_config = None
monkeypatch.setattr(ps, "prisma_client", mock_prisma)
monkeypatch.setattr(ps, "proxy_config", mock_cfg)
_set_admin()
audit_calls = []
async def capture(request_data):
audit_calls.append(request_data)
try:
with patch( # test-quality-ok: patching proxy-internal collaborator to isolate the endpoint
"litellm.proxy.management_helpers.audit_logs.create_audit_log_for_update",
new=capture,
):
r = client.post(
CYBERARK_URL,
json={
"cyberark_api_base": "https://conjur.example.com",
"cyberark_api_key": "my-very-secret-key",
},
)
assert r.status_code == 200
for _ in range(3):
await asyncio.sleep(0)
assert len(audit_calls) == 1
log = audit_calls[0]
assert log.action == "created"
assert log.object_id == "cyberark"
assert "my-very-secret-key" not in log.updated_values
assert "conjur.example.com" not in log.updated_values
after = json.loads(log.updated_values)
assert "cyberark_api_key" in after["config"]
assert "cyberark_api_base" in after["config"]
finally:
_cleanup()
@pytest.mark.asyncio
async def test_cyberark_test_connection(client, monkeypatch):
"""400 when not configured; success path authenticates and hits /whoami."""
from litellm.secret_managers.cyberark_secret_manager import CyberArkSecretManager
old_client, old_kms = litellm.secret_manager_client, litellm._key_management_system
_set_admin()
try:
# Not configured → 400
litellm.secret_manager_client = None # test-quality-ok: endpoint hot-reloads litellm globals; test must set and restore them
r = client.post(CYBERARK_URL + "/test_connection")
assert r.status_code == 400
assert "not configured" in r.json()["detail"].lower()
# Configured → authenticates and calls /whoami
mock_manager = MagicMock(spec=CyberArkSecretManager)
mock_manager.conjur_addr = "https://conjur.example.com"
mock_manager.ssl_verify = True
mock_manager._get_request_headers = MagicMock(
return_value={"Authorization": "Token abc"}
)
litellm.secret_manager_client = mock_manager # test-quality-ok: endpoint hot-reloads litellm globals; test must set and restore them
mock_response = MagicMock()
mock_response.raise_for_status = MagicMock()
mock_http = MagicMock()
mock_http.get = AsyncMock(return_value=mock_response)
with patch( # test-quality-ok: patching proxy-internal collaborator to isolate the endpoint
"litellm.proxy.management_endpoints.config_override_endpoints.get_async_httpx_client",
return_value=mock_http,
):
r = client.post(CYBERARK_URL + "/test_connection")
assert r.status_code == 200
assert "conjur.example.com" in r.json()["message"]
called_url = mock_http.get.call_args.args[0]
assert called_url == "https://conjur.example.com/whoami"
# Auth failure → 502
mock_manager._get_request_headers = MagicMock(
side_effect=Exception("bad credentials")
)
r = client.post(CYBERARK_URL + "/test_connection")
assert r.status_code == 502
assert "authentication failed" in r.json()["detail"].lower()
finally:
litellm.secret_manager_client = old_client # test-quality-ok: endpoint hot-reloads litellm globals; test must set and restore them
litellm._key_management_system = old_kms # test-quality-ok: endpoint hot-reloads litellm globals; test must set and restore them
_cleanup()
# ── Audit-log emission for /config_overrides/hashicorp_vault ─────────────────

View file

@ -9,6 +9,8 @@ const mockAddAllowedIP = vi.fn();
const mockDeleteAllowedIP = vi.fn();
vi.mock("@/components/networking", () => ({
getProxyBaseUrl: () => "http://localhost:4000",
getGlobalLitellmHeaderName: () => "Authorization",
getSSOSettings: (...args: unknown[]) => mockGetSSOSettings(...args),
getAllowedIPs: (...args: unknown[]) => mockGetAllowedIPs(...args),
addAllowedIP: (...args: unknown[]) => mockAddAllowedIP(...args),

View file

@ -18,6 +18,7 @@ import LoggingSettings from "@/components/Settings/AdminSettings/LoggingSettings
import SSOSettings from "@/components/Settings/AdminSettings/SSOSettings/SSOSettings";
import UISettings from "@/components/Settings/AdminSettings/UISettings/UISettings";
import UserBannerSettings from "@/components/Settings/AdminSettings/UserBannerSettings/UserBannerSettings";
import CyberArk from "@/components/Settings/AdminSettings/CyberArk/CyberArk";
import HashicorpVault from "@/components/Settings/AdminSettings/HashicorpVault/HashicorpVault";
import PluginSettings from "@/components/Settings/AdminSettings/PluginSettings/PluginSettings";
import SSOModals from "@/components/SSOModals";
@ -395,6 +396,11 @@ const AdminPanel: React.FC<AdminPanelProps> = ({ proxySettings }) => {
label: "Hashicorp Vault",
children: <HashicorpVault />,
},
{
key: "cyberark",
label: "CyberArk Conjur",
children: <CyberArk />,
},
{
key: "plugins",
label: "Plugins",

View file

@ -0,0 +1,38 @@
import { getProxyBaseUrl, getGlobalLitellmHeaderName } from "@/components/networking";
import { createApiClient } from "@/lib/http/client";
export interface CyberArkFieldSchema {
description?: string;
properties: Record<string, { description?: string; type?: string }>;
}
export interface CyberArkConfigResponse {
config_type: string;
values: Record<string, string | null>;
field_schema: CyberArkFieldSchema;
}
export interface CyberArkStatusResponse {
status: string;
message: string;
}
const apiClient = createApiClient({
getBaseUrl: getProxyBaseUrl,
getAuthHeaderName: getGlobalLitellmHeaderName,
});
export const getCyberArkConfig = async (accessToken: string): Promise<CyberArkConfigResponse> =>
apiClient.get<CyberArkConfigResponse>("/config_overrides/cyberark", { accessToken });
export const updateCyberArkConfig = async (
accessToken: string,
config: Record<string, string>,
): Promise<CyberArkStatusResponse> =>
apiClient.post<CyberArkStatusResponse>("/config_overrides/cyberark", { accessToken, body: config });
export const deleteCyberArkConfig = async (accessToken: string): Promise<CyberArkStatusResponse> =>
apiClient.delete<CyberArkStatusResponse>("/config_overrides/cyberark", { accessToken });
export const testCyberArkConnection = async (accessToken: string): Promise<CyberArkStatusResponse> =>
apiClient.post<CyberArkStatusResponse>("/config_overrides/cyberark/test_connection", { accessToken });

View file

@ -0,0 +1,24 @@
import { getCyberArkConfig, type CyberArkConfigResponse } from "./cyberArkApi";
import { useQuery } from "@tanstack/react-query";
import useAuthorized from "../useAuthorized";
import { createQueryKeys } from "../common/queryKeysFactory";
export const cyberArkKeys = createQueryKeys("cyberArkConfig");
export const useCyberArkConfig = () => {
const { accessToken } = useAuthorized();
const queryOptions = {
queryKey: cyberArkKeys.list({}),
queryFn: async () => {
if (!accessToken) {
throw new Error("Access token is required");
}
return getCyberArkConfig(accessToken);
},
enabled: !!accessToken,
staleTime: 60 * 60 * 1000,
gcTime: 60 * 60 * 1000,
};
return useQuery<CyberArkConfigResponse>(queryOptions);
};

View file

@ -0,0 +1,19 @@
import { deleteCyberArkConfig } from "./cyberArkApi";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { cyberArkKeys } from "./useCyberArkConfig";
export const useDeleteCyberArkConfig = (accessToken: string | null) => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async () => {
if (!accessToken) {
throw new Error("Access token is required");
}
return deleteCyberArkConfig(accessToken);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: cyberArkKeys.all });
},
});
};

View file

@ -0,0 +1,19 @@
import { updateCyberArkConfig } from "./cyberArkApi";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { cyberArkKeys } from "./useCyberArkConfig";
export const useUpdateCyberArkConfig = (accessToken: string | null) => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (config: Record<string, string>) => {
if (!accessToken) {
throw new Error("Access token is required");
}
return updateCyberArkConfig(accessToken, config);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: cyberArkKeys.all });
},
});
};

View file

@ -0,0 +1,78 @@
import { screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "../../../../../tests/test-utils";
import CyberArk from "./CyberArk";
const mockUseAuthorized = vi.hoisted(() => vi.fn());
const mockUseCyberArkConfig = vi.hoisted(() => vi.fn());
vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({
default: mockUseAuthorized,
}));
vi.mock("@/app/(dashboard)/hooks/configOverrides/useCyberArkConfig", () => ({
useCyberArkConfig: mockUseCyberArkConfig,
}));
vi.mock("@/app/(dashboard)/hooks/configOverrides/useDeleteCyberArkConfig", () => ({
useDeleteCyberArkConfig: () => ({ mutate: vi.fn(), isPending: false }),
}));
vi.mock("@/app/(dashboard)/hooks/configOverrides/useUpdateCyberArkConfig", () => ({
useUpdateCyberArkConfig: () => ({ mutate: vi.fn(), isPending: false }),
}));
vi.mock("./EditCyberArkModal", () => ({
default: ({ isVisible }: { isVisible: boolean }) => (isVisible ? <div>Edit CyberArk Configuration</div> : null),
}));
vi.mock("@/components/common_components/DeleteResourceModal", () => ({
default: () => null,
}));
describe("CyberArk", () => {
beforeEach(() => {
vi.clearAllMocks();
mockUseAuthorized.mockReturnValue({ accessToken: "test-token" });
const emptyConfigResult = {
data: { values: {} },
isLoading: false,
isError: false,
error: null,
};
mockUseCyberArkConfig.mockReturnValue(emptyConfigResult);
});
it("should render", () => {
renderWithProviders(<CyberArk />);
expect(screen.getByRole("heading", { name: "CyberArk Conjur" })).toBeInTheDocument();
});
it("should open the configuration editor from the empty state", async () => {
const user = userEvent.setup();
renderWithProviders(<CyberArk />);
await user.click(screen.getByRole("button", { name: /configure cyberark/i }));
expect(screen.getByText("Edit CyberArk Configuration")).toBeInTheDocument();
});
it("should display configured values and management actions", () => {
const configuredResult = {
data: { values: { cyberark_api_base: "https://conjur.example.com", cyberark_api_key: "secret" } },
isLoading: false,
isError: false,
error: null,
};
mockUseCyberArkConfig.mockReturnValue(configuredResult);
renderWithProviders(<CyberArk />);
expect(screen.getByText("https://conjur.example.com")).toBeInTheDocument();
expect(screen.getByText("Auth Method")).toBeInTheDocument();
expect(screen.getAllByText("API Key")).toHaveLength(2);
expect(screen.getByRole("button", { name: /test connection/i })).toBeInTheDocument();
});
});

View file

@ -0,0 +1,232 @@
"use client";
import { Edit, ExternalLink, Info, KeyRound, PlugZap, Trash2 } from "lucide-react";
import { useState } from "react";
import { testCyberArkConnection } from "@/app/(dashboard)/hooks/configOverrides/cyberArkApi";
import { useCyberArkConfig } from "@/app/(dashboard)/hooks/configOverrides/useCyberArkConfig";
import { useDeleteCyberArkConfig } from "@/app/(dashboard)/hooks/configOverrides/useDeleteCyberArkConfig";
import { useUpdateCyberArkConfig } from "@/app/(dashboard)/hooks/configOverrides/useUpdateCyberArkConfig";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import DeleteResourceModal from "@/components/common_components/DeleteResourceModal";
import { toast } from "@/lib/toast";
import { Alert, AlertDescription, AlertTitle } from "@/components/shared/Alert";
import { Button } from "@/components/ui/button";
import { Card, CardAction, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import CyberArkEmptyPlaceholder from "./CyberArkEmptyPlaceholder";
import EditCyberArkModal from "./EditCyberArkModal";
import { FIELD_LABELS, SENSITIVE_FIELDS } from "./constants";
function detectAuthMethod(values: Record<string, unknown>): string {
if (values.cyberark_api_key) return "API Key";
if (values.client_cert && values.client_key) return "TLS Certificate";
return "None";
}
function DetailRow({ children, label }: { children: React.ReactNode; label: string }) {
return (
<div className="grid grid-cols-1 sm:grid-cols-3">
<dt className="bg-muted/50 px-4 py-3 text-sm font-medium text-foreground">{label}</dt>
<dd className="px-4 py-3 text-sm text-foreground sm:col-span-2">{children}</dd>
</div>
);
}
export default function CyberArk() {
const { accessToken } = useAuthorized();
const { data, isLoading, isError, error } = useCyberArkConfig();
const { mutate: deleteConfig, isPending: isDeleting } = useDeleteCyberArkConfig(accessToken);
const { mutate: updateConfig, isPending: isClearingField } = useUpdateCyberArkConfig(accessToken);
const [isEditModalVisible, setIsEditModalVisible] = useState(false);
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [clearingField, setClearingField] = useState<string | null>(null);
const [isTesting, setIsTesting] = useState(false);
const rawValues = data?.values ?? {};
const isConfigured = Boolean(rawValues.cyberark_api_base);
const handleTestConnection = async () => {
if (!accessToken) return;
setIsTesting(true);
try {
const result = await testCyberArkConnection(accessToken);
toast.success(result.message || "Connection to CyberArk Conjur successful!");
} catch (err) {
toast.fromError(err);
} finally {
setIsTesting(false);
}
};
const handleDelete = () => {
deleteConfig(undefined, {
onSuccess: () => {
toast.success("CyberArk configuration deleted");
setIsDeleteModalOpen(false);
},
onError: (err) => toast.fromError(err),
});
};
const handleClearField = () => {
if (!clearingField) return;
updateConfig(
{ [clearingField]: "" },
{
onSuccess: () => {
toast.success(`${FIELD_LABELS[clearingField] ?? clearingField} cleared`);
setClearingField(null);
},
onError: (err) => toast.fromError(err),
},
);
};
const renderValue = (key: string) => {
const value = rawValues[key];
if (!value) return <span className="text-muted-foreground italic">Not configured</span>;
if (!SENSITIVE_FIELDS.has(key)) return <span className="font-mono text-muted-foreground">{value}</span>;
return (
<div className="flex items-center justify-between gap-2">
<span className="font-mono text-muted-foreground">{value}</span>
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label={`Clear ${FIELD_LABELS[key] ?? key}`}
onClick={() => setClearingField(key)}
>
<Trash2 className="size-3.5" />
</Button>
</div>
);
};
const fieldsToShow = Object.entries(rawValues).filter(([, value]) => value != null && value !== "");
const renderCard = () => {
if (isLoading) {
return (
<Card role="status" aria-label="Loading CyberArk configuration">
<CardContent className="space-y-3">
<Skeleton className="h-8 w-64" />
<Skeleton className="h-40 w-full" />
</CardContent>
</Card>
);
}
if (isError) {
return (
<Card>
<CardContent>
<Alert variant="error">
<AlertTitle>Could not load CyberArk configuration</AlertTitle>
{error instanceof Error && <AlertDescription>{error.message}</AlertDescription>}
</Alert>
</CardContent>
</Card>
);
}
return (
<Card>
<CardHeader>
<div className="flex items-center gap-3">
<KeyRound className="size-6 text-muted-foreground" />
<div>
<CardTitle>
<h3>CyberArk Conjur</h3>
</CardTitle>
<CardDescription>Manage secret manager configuration</CardDescription>
</div>
</div>
{isConfigured && (
<CardAction className="flex flex-wrap gap-2">
<Button type="button" variant="outline" disabled={isTesting} onClick={handleTestConnection}>
<PlugZap />
{isTesting ? "Testing..." : "Test Connection"}
</Button>
<Button type="button" variant="outline" onClick={() => setIsEditModalVisible(true)}>
<Edit />
Edit Configuration
</Button>
<Button type="button" variant="destructive" onClick={() => setIsDeleteModalOpen(true)}>
<Trash2 />
Delete Configuration
</Button>
</CardAction>
)}
</CardHeader>
<CardContent className="space-y-6">
{isConfigured && (
<Alert variant="info">
<Info />
<AlertTitle>Configuration changes are hot-reloaded across all proxy instances</AlertTitle>
<AlertDescription>
<a
href="https://docs.litellm.ai/docs/secret_managers/cyberark"
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1"
>
View documentation
<ExternalLink className="size-3" />
</a>
</AlertDescription>
</Alert>
)}
{isConfigured ? (
fieldsToShow.length > 0 && (
<dl className="divide-y divide-border overflow-hidden rounded-md border border-border">
<DetailRow label="Auth Method">{detectAuthMethod(rawValues)}</DetailRow>
{fieldsToShow.map(([key]) => (
<DetailRow key={key} label={FIELD_LABELS[key] ?? key}>
{renderValue(key)}
</DetailRow>
))}
</dl>
)
) : (
<CyberArkEmptyPlaceholder onAdd={() => setIsEditModalVisible(true)} />
)}
</CardContent>
</Card>
);
};
return (
<>
{renderCard()}
<EditCyberArkModal
isVisible={isEditModalVisible}
onCancel={() => setIsEditModalVisible(false)}
onSuccess={() => setIsEditModalVisible(false)}
/>
<DeleteResourceModal
isOpen={isDeleteModalOpen}
title="Delete CyberArk Configuration?"
message="Models using CyberArk secrets will lose access to their API keys until a new configuration is saved."
resourceInformationTitle="CyberArk Configuration"
resourceInformation={[{ label: "Conjur Server URL", value: rawValues.cyberark_api_base }]}
onCancel={() => setIsDeleteModalOpen(false)}
onOk={handleDelete}
confirmLoading={isDeleting}
/>
<DeleteResourceModal
isOpen={clearingField !== null}
title={`Clear ${clearingField ? FIELD_LABELS[clearingField] ?? clearingField : ""}?`}
message="This will remove the stored value."
resourceInformationTitle="Field"
resourceInformation={[
{ label: "Field", value: clearingField ? FIELD_LABELS[clearingField] ?? clearingField : "" },
]}
onCancel={() => setClearingField(null)}
onOk={handleClearField}
confirmLoading={isClearingField}
/>
</>
);
}

View file

@ -0,0 +1,24 @@
import { KeyRound } from "lucide-react";
import { Button } from "@/components/ui/button";
interface CyberArkEmptyPlaceholderProps {
onAdd: () => void;
}
export default function CyberArkEmptyPlaceholder({ onAdd }: CyberArkEmptyPlaceholderProps) {
return (
<div className="flex w-full flex-col items-center rounded-lg border border-dashed border-border bg-card p-12 text-center">
<div className="mb-4 flex size-12 items-center justify-center rounded-full bg-muted">
<KeyRound className="size-6 text-muted-foreground" />
</div>
<h4 className="text-base font-semibold text-foreground">No CyberArk Configuration Found</h4>
<p className="mx-auto mt-2 max-w-md text-sm text-muted-foreground">
Configure CyberArk Conjur to securely manage provider API keys and secrets for your LiteLLM deployment.
</p>
<Button size="lg" onClick={onAdd} className="mt-4">
Configure CyberArk
</Button>
</div>
);
}

View file

@ -0,0 +1,176 @@
import { fireEvent, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "../../../../../tests/test-utils";
import EditCyberArkModal from "./EditCyberArkModal";
import { useCyberArkConfig } from "@/app/(dashboard)/hooks/configOverrides/useCyberArkConfig";
import { useUpdateCyberArkConfig } from "@/app/(dashboard)/hooks/configOverrides/useUpdateCyberArkConfig";
vi.mock("@/app/(dashboard)/hooks/configOverrides/useCyberArkConfig", () => ({
useCyberArkConfig: vi.fn(),
}));
vi.mock("@/app/(dashboard)/hooks/configOverrides/useUpdateCyberArkConfig", () => ({
useUpdateCyberArkConfig: vi.fn(),
}));
vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({
default: () => ({ accessToken: "sk-access-token" }),
}));
vi.mock("@/lib/toast", () => ({
toast: { success: vi.fn(), fromError: vi.fn() },
}));
const ALL_FIELDS = [
"cyberark_api_base",
"cyberark_account",
"cyberark_username",
"cyberark_api_key",
"client_cert",
"client_key",
"ssl_verify",
"refresh_interval",
] as const;
const propertiesFor = (fields: readonly string[]) =>
Object.fromEntries(fields.map((name) => [name, { description: `${name} description` }]));
const mutate = vi.fn();
const setup = (options?: { values?: Record<string, unknown>; fields?: readonly string[] }) => {
vi.mocked(useCyberArkConfig).mockReturnValue({
data: {
field_schema: { properties: propertiesFor(options?.fields ?? ALL_FIELDS) },
values: options?.values ?? {},
},
} as unknown as ReturnType<typeof useCyberArkConfig>);
vi.mocked(useUpdateCyberArkConfig).mockReturnValue({
mutate,
isPending: false,
} as unknown as ReturnType<typeof useUpdateCyberArkConfig>);
};
const renderModal = (onSuccess = vi.fn(), onCancel = vi.fn()) =>
renderWithProviders(<EditCyberArkModal isVisible={true} onCancel={onCancel} onSuccess={onSuccess} />);
const save = async (user: ReturnType<typeof userEvent.setup>) =>
user.click(screen.getByRole("button", { name: "Save" }));
describe("EditCyberArkModal", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("clears untouched non-sensitive fields and omits untouched sensitive fields", async () => {
setup({
values: {
cyberark_api_base: "https://conjur.example.com",
cyberark_account: "myorg",
cyberark_api_key: "super-secret-key",
client_key: "super-secret-pem",
},
});
const user = userEvent.setup();
renderModal();
await save(user);
await waitFor(() => {
expect(mutate).toHaveBeenCalledTimes(1);
});
const expectedPayload = {
cyberark_api_base: "https://conjur.example.com",
cyberark_account: "myorg",
cyberark_username: "",
client_cert: "",
ssl_verify: "",
refresh_interval: "",
};
expect(mutate.mock.calls[0][0]).toEqual(expectedPayload);
});
it("sends a sensitive field only once it is typed into", async () => {
setup({ values: { cyberark_api_base: "https://conjur.example.com", cyberark_api_key: "super-secret-key" } });
const user = userEvent.setup();
renderModal();
fireEvent.change(screen.getByLabelText("API Key"), { target: { value: "rotated-key" } });
await save(user);
await waitFor(() => {
expect(mutate).toHaveBeenCalledTimes(1);
});
expect(mutate.mock.calls[0][0]).toMatchObject({ cyberark_api_key: "rotated-key" });
});
it("never seeds a stored secret into its input", () => {
setup({ values: { cyberark_api_key: "super-secret-key", client_key: "super-secret-pem" } });
renderModal();
expect(screen.getByLabelText("API Key")).toHaveValue("");
expect(screen.getByLabelText("Client Key")).toHaveValue("");
});
it("renders only the fields the schema declares, and sends only those", async () => {
setup({
fields: ["cyberark_api_base", "cyberark_api_key"],
values: { cyberark_api_base: "https://conjur.example.com" },
});
const user = userEvent.setup();
renderModal();
expect(screen.queryByLabelText("Account")).not.toBeInTheDocument();
expect(screen.queryByLabelText("Client Key")).not.toBeInTheDocument();
await save(user);
await waitFor(() => {
expect(mutate).toHaveBeenCalledTimes(1);
});
expect(mutate.mock.calls[0][0]).toEqual({ cyberark_api_base: "https://conjur.example.com" });
});
it("blocks the submit when the server url does not start with http", async () => {
setup({ values: {} });
const user = userEvent.setup();
renderModal();
fireEvent.change(screen.getByLabelText("Conjur Server URL"), { target: { value: "conjur.example.com" } });
await save(user);
expect(await screen.findByText("Must start with http:// or https://")).toBeInTheDocument();
expect(mutate).not.toHaveBeenCalled();
});
it("tells the admin a stored secret is kept when the field is left blank", () => {
setup({ values: { cyberark_api_key: "super-secret-key" } });
renderModal();
expect(screen.getByLabelText("API Key")).toHaveAttribute(
"placeholder",
"Leave blank to keep existing (super-secret-key)",
);
});
it("falls back to the schema description when no secret is stored yet", () => {
setup({ values: {} });
renderModal();
expect(screen.getByLabelText("API Key")).toHaveAttribute("placeholder", "cyberark_api_key description");
});
it("closes without saving when cancelled", async () => {
setup({ values: {} });
const onCancel = vi.fn();
const user = userEvent.setup();
renderModal(vi.fn(), onCancel);
await user.click(screen.getByRole("button", { name: "Cancel" }));
expect(onCancel).toHaveBeenCalledTimes(1);
expect(mutate).not.toHaveBeenCalled();
});
});

View file

@ -0,0 +1,176 @@
"use client";
import { useCyberArkConfig } from "@/app/(dashboard)/hooks/configOverrides/useCyberArkConfig";
import { useUpdateCyberArkConfig } from "@/app/(dashboard)/hooks/configOverrides/useUpdateCyberArkConfig";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import { toast } from "@/lib/toast";
import React, { useMemo } from "react";
import { z } from "zod/v4";
import { FieldGroup } from "@/components/ui/field";
import { FormField } from "@/components/shared/form/FormField";
import { PasswordInput } from "@/components/shared/PasswordInput";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner";
import { Separator } from "@/components/ui/separator";
import { useZodForm } from "@/lib/forms/useZodForm";
import { SENSITIVE_FIELDS, FIELD_LABELS } from "./constants";
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
interface CyberArkFieldGroup {
title: string;
subtitle?: string;
fields: string[];
}
const FIELD_GROUPS: CyberArkFieldGroup[] = [
{
title: "Connection",
fields: ["cyberark_api_base", "cyberark_account", "cyberark_username"],
},
{
title: "API Key Authentication",
subtitle: "Use a Conjur API key to authenticate. Only one auth method is required.",
fields: ["cyberark_api_key"],
},
{
title: "Certificate Authentication",
subtitle: "Use a client TLS certificate and key to authenticate. Only one auth method is required.",
fields: ["client_cert", "client_key"],
},
{
title: "Advanced",
subtitle: "Optional TLS and token caching settings.",
fields: ["ssl_verify", "refresh_interval"],
},
];
type CyberArkFormValues = Record<string, string>;
const buildSchema = (fields: readonly string[]): z.ZodType<CyberArkFormValues, CyberArkFormValues> =>
z.object(
Object.fromEntries(
fields.map((name) => [
name,
name === "cyberark_api_base"
? z.string().refine((value) => value.length === 0 || /^https?:\/\/.+/.test(value), {
message: "Must start with http:// or https://",
})
: z.string(),
]),
),
) as unknown as z.ZodType<CyberArkFormValues, CyberArkFormValues>;
interface EditCyberArkModalProps {
isVisible: boolean;
onCancel: () => void;
onSuccess: () => void;
}
const EditCyberArkModal: React.FC<EditCyberArkModalProps> = ({ isVisible, onCancel, onSuccess }) => {
const { accessToken } = useAuthorized();
const { data } = useCyberArkConfig();
const { mutate, isPending } = useUpdateCyberArkConfig(accessToken);
const properties: Record<string, { description?: string }> = useMemo(
() => data?.field_schema?.properties ?? {},
[data],
);
const rawValues: Record<string, unknown> = useMemo(() => data?.values ?? {}, [data]);
const visibleFields = useMemo(
() => FIELD_GROUPS.flatMap((group) => group.fields).filter((name) => properties[name] !== undefined),
[properties],
);
const seededValues = useMemo(
() =>
Object.fromEntries(
visibleFields.map((name) => [name, SENSITIVE_FIELDS.has(name) ? "" : ((rawValues[name] ?? "") as string)]),
),
[visibleFields, rawValues],
);
const schema = useMemo(() => buildSchema(visibleFields), [visibleFields]);
const form = useZodForm(schema, { values: seededValues });
const handleSubmit = (formValues: CyberArkFormValues) => {
const config: Record<string, string> = Object.fromEntries(
Object.entries(formValues).flatMap(([key, value]) => {
if (value !== undefined && value !== null && value !== "") return [[key, value]];
if (!SENSITIVE_FIELDS.has(key)) return [[key, ""]];
return [];
}),
);
mutate(config, {
onSuccess: () => {
toast.success("CyberArk configuration updated successfully");
onSuccess();
},
onError: (err) => {
toast.fromError(err);
},
});
};
const handleCancel = () => {
form.reset(seededValues);
onCancel();
};
const renderField = (fieldName: string) => {
const fieldSchema = properties[fieldName];
if (!fieldSchema) return null;
const isSensitive = SENSITIVE_FIELDS.has(fieldName);
const existingValue = rawValues[fieldName];
const hasExistingValue = isSensitive && existingValue != null && existingValue !== "";
const placeholder = hasExistingValue ? `Leave blank to keep existing (${existingValue})` : fieldSchema?.description;
return (
<FormField key={fieldName} control={form.control} name={fieldName} label={FIELD_LABELS[fieldName] ?? fieldName}>
{({ ref, ...field }) =>
isSensitive ? (
<PasswordInput ref={ref} placeholder={placeholder} {...field} />
) : (
<Input ref={ref} placeholder={fieldSchema?.description} {...field} />
)
}
</FormField>
);
};
return (
<Dialog open={isVisible} onOpenChange={(open) => !open && handleCancel()}>
<DialogContent className="max-h-[calc(100dvh-2rem)] overflow-y-auto sm:max-w-[700px]">
<DialogHeader>
<DialogTitle>Edit CyberArk Configuration</DialogTitle>
</DialogHeader>
<form onSubmit={form.handleSubmit(handleSubmit)}>
{FIELD_GROUPS.map((group, index) => (
<div key={group.title}>
{index > 0 && <Separator className="my-6" />}
<h5 className="mb-1 text-base font-semibold text-foreground">{group.title}</h5>
{group.subtitle && <p className="mb-4 text-sm text-muted-foreground">{group.subtitle}</p>}
<FieldGroup>{group.fields.map(renderField)}</FieldGroup>
</div>
))}
</form>
<DialogFooter>
<div className="flex items-center justify-end gap-2">
<Button type="button" variant="outline" onClick={handleCancel} disabled={isPending}>
Cancel
</Button>
<Button type="button" disabled={isPending} onClick={() => void form.handleSubmit(handleSubmit)()}>
{isPending && <UiLoadingSpinner className="size-4 mr-1" />}
{isPending ? "Saving..." : "Save"}
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
export default EditCyberArkModal;

View file

@ -0,0 +1,12 @@
export const SENSITIVE_FIELDS = new Set(["cyberark_api_key", "client_key"]);
export const FIELD_LABELS: Record<string, string> = {
cyberark_api_base: "Conjur Server URL",
cyberark_account: "Account",
cyberark_username: "Username",
cyberark_api_key: "API Key",
client_cert: "Client Certificate",
client_key: "Client Key",
ssl_verify: "SSL Verification",
refresh_interval: "Token Refresh Interval (seconds)",
};

View file

@ -2858,6 +2858,59 @@ export interface paths {
patch?: never;
trace?: never;
};
"/config_overrides/cyberark": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* Get Cyberark Config
* @description Get current CyberArk Conjur configuration.
* Returns decrypted values from DB, or falls back to current env vars.
* Sensitive fields are masked before leaving the server.
*/
get: operations["get_cyberark_config_config_overrides_cyberark_get"];
put?: never;
/**
* Update Cyberark Config
* @description Update CyberArk Conjur secret manager configuration.
* Sets environment variables, encrypts sensitive fields, and stores in DB.
* Reinitializes the secret manager on this pod.
*/
post: operations["update_cyberark_config_config_overrides_cyberark_post"];
/**
* Delete Cyberark Config
* @description Delete CyberArk Conjur configuration. Idempotent.
*/
delete: operations["delete_cyberark_config_config_overrides_cyberark_delete"];
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/config_overrides/cyberark/test_connection": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/**
* Test Cyberark Connection
* @description Test the connection to the currently configured CyberArk Conjur server.
* Uses the already-initialized secret manager client. Does not modify any state.
*/
post: operations["test_cyberark_connection_config_overrides_cyberark_test_connection_post"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/config_overrides/hashicorp_vault": {
parameters: {
query?: never;
@ -25841,6 +25894,52 @@ export interface components {
/** User Id */
user_id: string;
};
/**
* CyberArkConfig
* @description Configuration for CyberArk Conjur secret manager integration.
*/
CyberArkConfig: {
/**
* Client Cert
* @description Path to the client TLS certificate for certificate-based authentication
*/
client_cert?: string | null;
/**
* Client Key
* @description Path to the client TLS private key for certificate-based authentication
*/
client_key?: string | null;
/**
* Cyberark Account
* @description The Conjur organization account name
*/
cyberark_account?: string | null;
/**
* Cyberark Api Base
* @description The address of the CyberArk Conjur server (e.g., https://conjur.example.com)
*/
cyberark_api_base?: string | null;
/**
* Cyberark Api Key
* @description API key for Conjur API-key authentication
*/
cyberark_api_key?: string | null;
/**
* Cyberark Username
* @description The Conjur username (login) to authenticate as
*/
cyberark_username?: string | null;
/**
* Refresh Interval
* @description Auth token cache TTL in seconds (default: 300)
*/
refresh_interval?: string | null;
/**
* Ssl Verify
* @description Set to false to disable SSL verification (e.g., for self-signed certificates)
*/
ssl_verify?: string | null;
};
/** DailySpendData */
DailySpendData: {
breakdown?: components["schemas"]["BreakdownMetrics"];
@ -42786,6 +42885,120 @@ export interface operations {
};
};
};
get_cyberark_config_config_overrides_cyberark_get: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ConfigOverrideSettingsResponse"];
};
};
};
};
update_cyberark_config_config_overrides_cyberark_post: {
parameters: {
query?: never;
header?: {
/** @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 */
"litellm-changed-by"?: string | null;
};
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["CyberArkConfig"];
};
};
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
[key: string]: string;
};
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
delete_cyberark_config_config_overrides_cyberark_delete: {
parameters: {
query?: never;
header?: {
/** @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 */
"litellm-changed-by"?: string | null;
};
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
[key: string]: string;
};
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
test_cyberark_connection_config_overrides_cyberark_test_connection_post: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
[key: string]: string;
};
};
};
};
};
get_hashicorp_vault_config_config_overrides_hashicorp_vault_get: {
parameters: {
query?: never;