diff --git a/litellm/proxy/management_endpoints/access_group_endpoints.py b/litellm/proxy/management_endpoints/access_group_endpoints.py index caaec12f7a..61996850bb 100644 --- a/litellm/proxy/management_endpoints/access_group_endpoints.py +++ b/litellm/proxy/management_endpoints/access_group_endpoints.py @@ -38,6 +38,17 @@ def _require_proxy_admin(user_api_key_dict: UserAPIKeyAuth) -> None: ) +def _require_admin_view(user_api_key_dict: UserAPIKeyAuth) -> None: + """Admin Viewer parity: PROXY_ADMIN or PROXY_ADMIN_VIEW_ONLY may read.""" + from litellm.proxy.management_endpoints.common_utils import _user_has_admin_view + + if not _user_has_admin_view(user_api_key_dict): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail={"error": CommonProxyErrors.not_allowed_access.value}, + ) + + def _record_to_response(record) -> AccessGroupResponse: return AccessGroupResponse( access_group_id=record.access_group_id, @@ -372,7 +383,7 @@ async def create_access_group( async def list_access_groups( user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), ) -> List[AccessGroupResponse]: - _require_proxy_admin(user_api_key_dict) + _require_admin_view(user_api_key_dict) prisma_client = get_prisma_client_or_throw( CommonProxyErrors.db_not_connected_error.value ) @@ -391,7 +402,7 @@ async def get_access_group( access_group_id: str, user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), ) -> AccessGroupResponse: - _require_proxy_admin(user_api_key_dict) + _require_admin_view(user_api_key_dict) prisma_client = get_prisma_client_or_throw( CommonProxyErrors.db_not_connected_error.value ) diff --git a/litellm/proxy/management_endpoints/config_override_endpoints.py b/litellm/proxy/management_endpoints/config_override_endpoints.py index d78c5526e6..b736ba1081 100644 --- a/litellm/proxy/management_endpoints/config_override_endpoints.py +++ b/litellm/proxy/management_endpoints/config_override_endpoints.py @@ -267,9 +267,11 @@ async def get_hashicorp_vault_config( Get current Hashicorp Vault configuration. Returns decrypted values from DB, or falls back to current env vars. """ + from litellm.proxy.management_endpoints.common_utils import _user_has_admin_view from litellm.proxy.proxy_server import prisma_client, proxy_config - if user_api_key_dict.user_role != LitellmUserRoles.PROXY_ADMIN: + # Admin Viewer follows the read-parity rule. + if not _user_has_admin_view(user_api_key_dict): raise HTTPException( status_code=403, detail="Only admin users can view config overrides", diff --git a/litellm/proxy/management_endpoints/jwt_key_mapping_endpoints.py b/litellm/proxy/management_endpoints/jwt_key_mapping_endpoints.py index e474cb7d15..1ee5bfb022 100644 --- a/litellm/proxy/management_endpoints/jwt_key_mapping_endpoints.py +++ b/litellm/proxy/management_endpoints/jwt_key_mapping_endpoints.py @@ -10,6 +10,7 @@ from litellm.proxy._types import ( hash_token, ) from litellm.proxy.auth.user_api_key_auth import user_api_key_auth +from litellm.proxy.management_endpoints.common_utils import _user_has_admin_view router = APIRouter() @@ -194,7 +195,8 @@ async def list_jwt_key_mappings( ): from litellm.proxy.proxy_server import prisma_client - if user_api_key_dict.user_role != LitellmUserRoles.PROXY_ADMIN: + # Admin Viewer follows the read-parity rule. + if not _user_has_admin_view(user_api_key_dict): raise HTTPException( status_code=403, detail="Only proxy admins can list JWT key mappings" ) @@ -233,7 +235,8 @@ async def info_jwt_key_mapping( ): from litellm.proxy.proxy_server import prisma_client - if user_api_key_dict.user_role != LitellmUserRoles.PROXY_ADMIN: + # Admin Viewer follows the read-parity rule. + if not _user_has_admin_view(user_api_key_dict): raise HTTPException( status_code=403, detail="Only proxy admins can get JWT key mapping info" ) diff --git a/litellm/proxy/management_endpoints/mcp_management_endpoints.py b/litellm/proxy/management_endpoints/mcp_management_endpoints.py index fca08e591f..cc84c36f9c 100644 --- a/litellm/proxy/management_endpoints/mcp_management_endpoints.py +++ b/litellm/proxy/management_endpoints/mcp_management_endpoints.py @@ -2119,7 +2119,8 @@ if MCP_AVAILABLE: Used by the UI to show a discovery grid when adding new MCP servers. """ - if user_api_key_dict.user_role != LitellmUserRoles.PROXY_ADMIN: + # Admin Viewer follows the read-parity rule. + if not _user_has_admin_view(user_api_key_dict): raise HTTPException( status_code=403, detail={ @@ -2176,7 +2177,8 @@ if MCP_AVAILABLE: async def get_openapi_registry( user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), ): - if user_api_key_dict.user_role != LitellmUserRoles.PROXY_ADMIN: + # Admin Viewer follows the read-parity rule. + if not _user_has_admin_view(user_api_key_dict): raise HTTPException( status_code=403, detail={ diff --git a/litellm/proxy/management_endpoints/team_endpoints.py b/litellm/proxy/management_endpoints/team_endpoints.py index f254fea3e7..d53c1e7130 100644 --- a/litellm/proxy/management_endpoints/team_endpoints.py +++ b/litellm/proxy/management_endpoints/team_endpoints.py @@ -4623,9 +4623,11 @@ async def team_member_permissions( complete_team_data = LiteLLM_TeamTable(**existing_team_row.model_dump()) + # Admin Viewer follows the read-parity rule: see team permissions like + # a Proxy Admin would. Team / org admins keep their existing scope. if ( hasattr(user_api_key_dict, "user_role") - and user_api_key_dict.user_role != LitellmUserRoles.PROXY_ADMIN.value + and not _user_has_admin_view(user_api_key_dict) and not _is_user_team_admin( user_api_key_dict=user_api_key_dict, team_obj=complete_team_data ) diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index 6e7e98205d..5f5566c7c5 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -14058,8 +14058,8 @@ async def get_anthropic_beta_headers_reload_status( Get the status of the scheduled Anthropic beta headers reload job. """ - # Check if user is admin - if user_api_key_dict.user_role != LitellmUserRoles.PROXY_ADMIN: + # Read-only status — admin viewers can read. + if not _user_has_admin_view(user_api_key_dict): raise HTTPException( status_code=403, detail=f"Access denied. Admin role required. Current role: {user_api_key_dict.user_role}", @@ -14165,7 +14165,8 @@ async def get_adaptive_router_state( adaptive-router deployment. Each snapshot's `router_name` field identifies which deployment it came from. """ - if user_api_key_dict.user_role != LitellmUserRoles.PROXY_ADMIN: + # Read-only state — admin viewers can read. + if not _user_has_admin_view(user_api_key_dict): raise HTTPException( status_code=403, detail={"error": CommonProxyErrors.not_allowed_access.value}, diff --git a/litellm/proxy/spend_tracking/cloudzero_endpoints.py b/litellm/proxy/spend_tracking/cloudzero_endpoints.py index c7bff7ec64..1f551d5ffe 100644 --- a/litellm/proxy/spend_tracking/cloudzero_endpoints.py +++ b/litellm/proxy/spend_tracking/cloudzero_endpoints.py @@ -6,6 +6,7 @@ from litellm._logging import verbose_proxy_logger from litellm.litellm_core_utils.sensitive_data_masker import SensitiveDataMasker from litellm.proxy._types import CommonProxyErrors, LitellmUserRoles, UserAPIKeyAuth from litellm.proxy.auth.user_api_key_auth import user_api_key_auth +from litellm.proxy.management_endpoints.common_utils import _user_has_admin_view from litellm.proxy.common_utils.encrypt_decrypt_utils import ( decrypt_value_helper, encrypt_value_helper, @@ -127,10 +128,10 @@ async def get_cloudzero_settings( Only the first 4 and last 4 characters of the API key are shown. Returns null/empty values when settings are not configured (consistent with other settings endpoints). - Only admin users can view CloudZero settings. + Only admin users (Proxy Admin or Admin Viewer) can view CloudZero settings. """ - # Validation - if user_api_key_dict.user_role != LitellmUserRoles.PROXY_ADMIN: + # Validation — Admin Viewer follows the read-parity rule. + if not _user_has_admin_view(user_api_key_dict): raise HTTPException( status_code=403, detail={"error": CommonProxyErrors.not_allowed_access.value}, diff --git a/litellm/proxy/spend_tracking/vantage_endpoints.py b/litellm/proxy/spend_tracking/vantage_endpoints.py index 7d8fbf7461..60e54d005b 100644 --- a/litellm/proxy/spend_tracking/vantage_endpoints.py +++ b/litellm/proxy/spend_tracking/vantage_endpoints.py @@ -7,6 +7,7 @@ from litellm._logging import verbose_proxy_logger from litellm.litellm_core_utils.sensitive_data_masker import SensitiveDataMasker from litellm.proxy._types import CommonProxyErrors, LitellmUserRoles, UserAPIKeyAuth from litellm.proxy.auth.user_api_key_auth import user_api_key_auth +from litellm.proxy.management_endpoints.common_utils import _user_has_admin_view from litellm.proxy.common_utils.encrypt_decrypt_utils import ( decrypt_value_helper, encrypt_value_helper, @@ -140,9 +141,10 @@ async def get_vantage_settings( View current Vantage settings. Returns the current Vantage configuration with the API key masked for security. - Only admin users can view Vantage settings. + Only admin users (Proxy Admin or Admin Viewer) can view Vantage settings. """ - if user_api_key_dict.user_role != LitellmUserRoles.PROXY_ADMIN: + # Admin Viewer follows the read-parity rule. + if not _user_has_admin_view(user_api_key_dict): raise HTTPException( status_code=403, detail={"error": CommonProxyErrors.not_allowed_access.value}, diff --git a/ui/litellm-dashboard/src/components/AIHub/ModelHubTable.tsx b/ui/litellm-dashboard/src/components/AIHub/ModelHubTable.tsx index 39695c1348..75058157a6 100644 --- a/ui/litellm-dashboard/src/components/AIHub/ModelHubTable.tsx +++ b/ui/litellm-dashboard/src/components/AIHub/ModelHubTable.tsx @@ -22,7 +22,7 @@ import { modelHubPublicModelsCall, } from "@/components/networking"; import PublicModelHub from "@/components/public_model_hub"; -import { isAdminRole } from "@/utils/roles"; +import { isAdminRole, isProxyAdminRole } from "@/utils/roles"; import { CopyOutlined } from "@ant-design/icons"; import { Badge, Button, Card, Tab, TabGroup, TabList, TabPanel, TabPanels, Text, Title } from "@tremor/react"; import { Modal } from "antd"; @@ -61,6 +61,10 @@ interface ModelGroupInfo { } const ModelHubTable: React.FC = ({ accessToken, publicPage, premiumUser, userRole }) => { + // Admin Viewer follows the read-parity rule: see the AI Hub catalog, but + // cannot toggle public visibility (write). + const canModify = isProxyAdminRole(userRole || ""); + const [publicPageAllowed, setPublicPageAllowed] = useState(false); const [modelHubData, setModelHubData] = useState(null); const [loading, setLoading] = useState(true); @@ -420,7 +424,7 @@ const ModelHubTable: React.FC = ({ accessToken, publicPage, {/* Useful Links Management Section for Admins */} - {isAdminRole(userRole || "") && ( + {canModify && (
@@ -441,7 +445,7 @@ const ModelHubTable: React.FC = ({ accessToken, publicPage, {/* Model Filters and Table */} {/* Header with Make Public Button */} - {publicPage == false && isAdminRole(userRole || "") && ( + {publicPage == false && canModify && (
@@ -470,7 +474,7 @@ const ModelHubTable: React.FC = ({ accessToken, publicPage, {/* Header with Make Public Button */} - {publicPage == false && isAdminRole(userRole || "") && ( + {publicPage == false && canModify && (
@@ -496,7 +500,7 @@ const ModelHubTable: React.FC = ({ accessToken, publicPage, {/* Header with Make Public Button */} - {publicPage == false && isAdminRole(userRole || "") && ( + {publicPage == false && canModify && (
@@ -520,7 +524,7 @@ const ModelHubTable: React.FC = ({ accessToken, publicPage, {/* Skill Hub Tab */} - {publicPage == false && isAdminRole(userRole || "") && ( + {publicPage == false && canModify && (
+ {canModify && ( + + )} diff --git a/ui/litellm-dashboard/src/components/Settings/RouterSettings/Fallbacks/Fallbacks.tsx b/ui/litellm-dashboard/src/components/Settings/RouterSettings/Fallbacks/Fallbacks.tsx index 9c5933aba3..b4ab7ddddb 100644 --- a/ui/litellm-dashboard/src/components/Settings/RouterSettings/Fallbacks/Fallbacks.tsx +++ b/ui/litellm-dashboard/src/components/Settings/RouterSettings/Fallbacks/Fallbacks.tsx @@ -8,6 +8,7 @@ import DeleteResourceModal from "../../../common_components/DeleteResourceModal" import { ProviderLogo } from "../../../molecules/models/ProviderLogo"; import NotificationsManager from "../../../molecules/notifications_manager"; import { getCallbacksCall, setCallbacksCall } from "../../../networking"; +import { isProxyAdminRole } from "@/utils/roles"; import AddFallbacks from "./AddFallbacks"; type FallbackEntry = { [modelName: string]: string[] }; @@ -243,15 +244,19 @@ const Fallbacks: React.FC = ({ accessToken, userRole, userID, mo }; const hasFallbacks = Array.isArray(routerSettings.fallbacks) && routerSettings.fallbacks.length > 0; + // Admin Viewer follows the read-parity rule: see fallbacks, no writes. + const canModify = isProxyAdminRole(userRole ?? ""); return ( <> - data.model_name) : []} - accessToken={accessToken || ""} - value={routerSettings.fallbacks || []} - onChange={handleFallbacksChange} - /> + {canModify && ( + data.model_name) : []} + accessToken={accessToken || ""} + value={routerSettings.fallbacks || []} + onChange={handleFallbacksChange} + /> + )} {!hasFallbacks ? (
@@ -280,30 +285,34 @@ const Fallbacks: React.FC = ({ accessToken, userRole, userID, mo {renderFallbacksChain(key, Array.isArray(value) ? value : [], getProviderFromModel)} - - testFallbackModelResponse(Object.keys(item)[0], accessToken || "")} - className="cursor-pointer hover:text-blue-600" - /> - - - handleDeleteClick(item)} - onKeyDown={(e) => e.key === "Enter" && handleDeleteClick(item)} - className="cursor-pointer inline-flex" - > - - - + {canModify && ( + <> + + testFallbackModelResponse(Object.keys(item)[0], accessToken || "")} + className="cursor-pointer hover:text-blue-600" + /> + + + handleDeleteClick(item)} + onKeyDown={(e) => e.key === "Enter" && handleDeleteClick(item)} + className="cursor-pointer inline-flex" + > + + + + + )} )), diff --git a/ui/litellm-dashboard/src/components/budgets/budget_panel.tsx b/ui/litellm-dashboard/src/components/budgets/budget_panel.tsx index e42d056965..d90737b130 100644 --- a/ui/litellm-dashboard/src/components/budgets/budget_panel.tsx +++ b/ui/litellm-dashboard/src/components/budgets/budget_panel.tsx @@ -28,6 +28,8 @@ import { useBudgets, useDeleteBudget } from "@/app/(dashboard)/hooks/budgets/use import BudgetModal from "./budget_modal"; import EditBudgetModal from "./edit_budget_modal"; import { CREATE_END_USER_CURL_COMMAND, CHAT_COMPLETIONS_CURL_COMMAND, OPENAI_SDK_PYTHON_CODE } from "./constants"; +import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; +import { isProxyAdminRole } from "@/utils/roles"; interface BudgetSettingsPageProps { accessToken: string | null; @@ -47,6 +49,10 @@ const BudgetPanel: React.FC = ({ accessToken }) => { const [selectedBudget, setSelectedBudget] = useState(null); const [isDeleteModalVisible, setIsDeleteModalVisible] = useState(false); + const { userRole } = useAuthorized(); + // Admin Viewer follows the read-parity rule: see budgets, no writes. + const canModify = isProxyAdminRole(userRole ?? ""); + const { data: budgetList = [] } = useBudgets(); const deleteBudget = useDeleteBudget(); @@ -89,9 +95,11 @@ const BudgetPanel: React.FC = ({ accessToken }) => { return (
- + {canModify && ( + + )} Budgets @@ -133,18 +141,22 @@ const BudgetPanel: React.FC = ({ accessToken }) => { {value.max_budget ? value.max_budget : "n/a"} {value.tpm_limit ? value.tpm_limit : "n/a"} {value.rpm_limit ? value.rpm_limit : "n/a"} - handleEditCall(value)} - dataTestId="edit-budget-button" - /> - handleDeleteClick(value)} - dataTestId="delete-budget-button" - /> + {canModify && ( + <> + handleEditCall(value)} + dataTestId="edit-budget-button" + /> + handleDeleteClick(value)} + dataTestId="delete-budget-button" + /> + + )} ))} diff --git a/ui/litellm-dashboard/src/components/prompts.tsx b/ui/litellm-dashboard/src/components/prompts.tsx index 8e2e9c8b11..1e0155a773 100644 --- a/ui/litellm-dashboard/src/components/prompts.tsx +++ b/ui/litellm-dashboard/src/components/prompts.tsx @@ -8,7 +8,7 @@ import PromptInfoView from "./prompts/prompt_info"; import AddPromptForm from "./prompts/add_prompt_form"; import PromptEditorView from "./prompts/prompt_editor_view"; import NotificationsManager from "./molecules/notifications_manager"; -import { isAdminRole } from "@/utils/roles"; +import { isAdminRole, isProxyAdminRole } from "@/utils/roles"; interface PromptsProps { accessToken: string | null; @@ -27,6 +27,8 @@ const PromptsPanel: React.FC = ({ accessToken, userRole }) => { const [promptToDelete, setPromptToDelete] = useState<{ id: string; name: string } | null>(null); const isAdmin = userRole ? isAdminRole(userRole) : false; + // Admin Viewer follows the read-parity rule: see prompts, no writes. + const canModify = userRole ? isProxyAdminRole(userRole) : false; const fetchPrompts = async () => { if (!accessToken) { @@ -128,7 +130,7 @@ const PromptsPanel: React.FC = ({ accessToken, userRole }) => { promptId={selectedPromptId} onClose={() => setSelectedPromptId(null)} accessToken={accessToken} - isAdmin={isAdmin} + isAdmin={canModify} onDelete={fetchPrompts} onEdit={handleEditPrompt} /> @@ -136,12 +138,16 @@ const PromptsPanel: React.FC = ({ accessToken, userRole }) => { <>
- - + {canModify && ( + <> + + + + )}