mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-05 08:07:05 +00:00
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:
parent
8dd9c4acb1
commit
645792955d
20 changed files with 2195 additions and 16 deletions
|
|
@ -24,7 +24,7 @@
|
|||
"limit": 19
|
||||
},
|
||||
"reportExplicitAny": {
|
||||
"limit": 5486
|
||||
"limit": 5485
|
||||
},
|
||||
"reportFunctionMemberAccess": {
|
||||
"limit": 7
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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}",
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 ─────────────────
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
@ -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);
|
||||
};
|
||||
|
|
@ -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 });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
|
@ -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 });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
|
|
@ -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)",
|
||||
};
|
||||
213
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
213
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue