From 7a27434f890db534bb1ebd723c925b28fb2ad275 Mon Sep 17 00:00:00 2001 From: Ryan Crabbe Date: Thu, 2 Apr 2026 17:21:54 -0700 Subject: [PATCH 1/8] feat(ui): add submit guardrail form to Submitted Guardrails tab Wire the placeholder "Add Guardrail" button to open an antd Modal+Form with team selector, guardrail name, mode, API base URL, optional extra litellm_params JSON, and optional guardrail_info JSON. Backend call is stubbed with a TODO for now. --- .../guardrails/TeamGuardrailsTab.tsx | 128 ++++++++++++++++++ 1 file changed, 128 insertions(+) diff --git a/ui/litellm-dashboard/src/components/guardrails/TeamGuardrailsTab.tsx b/ui/litellm-dashboard/src/components/guardrails/TeamGuardrailsTab.tsx index a2246fd976d..c98ad18dcab 100644 --- a/ui/litellm-dashboard/src/components/guardrails/TeamGuardrailsTab.tsx +++ b/ui/litellm-dashboard/src/components/guardrails/TeamGuardrailsTab.tsx @@ -14,6 +14,7 @@ import { AlertCircleIcon, InfoIcon, } from "lucide-react"; +import { Modal, Form, Input, Select } from "antd"; import { listGuardrailSubmissions, approveGuardrailSubmission, @@ -22,6 +23,7 @@ import { type GuardrailSubmissionItem, } from "@/components/networking"; import NotificationsManager from "@/components/molecules/notifications_manager"; +import TeamDropdown from "@/components/common_components/team_dropdown"; type GuardrailStatus = "active" | "pending" | "rejected"; @@ -820,6 +822,8 @@ export function TeamGuardrailsTab({ accessToken }: TeamGuardrailsTabProps) { const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); const [searchDebounced, setSearchDebounced] = useState(""); + const [isSubmitModalOpen, setIsSubmitModalOpen] = useState(false); + const [submitForm] = Form.useForm(); useEffect(() => { const t = setTimeout(() => setSearchDebounced(search), 300); @@ -1006,6 +1010,7 @@ export function TeamGuardrailsTab({ accessToken }: TeamGuardrailsTabProps) { - - - - {selectedGuardrailId ? ( - setSelectedGuardrailId(null)} + {isAdmin && ( + + - ) : ( - + )} + + {isAdmin && ( + +
+ , + label: "Add Provider Guardrail", + onClick: handleAddGuardrail, + }, + { + key: "custom_code", + icon: , + label: "Create Custom Code Guardrail", + onClick: handleAddCustomCodeGuardrail, + }, + ], + }} + trigger={["click"]} + disabled={!accessToken} + > + + +
+ + {selectedGuardrailId ? ( + setSelectedGuardrailId(null)} + accessToken={accessToken} + isAdmin={isAdmin} + /> + ) : ( + setSelectedGuardrailId(id)} + /> + )} + + setSelectedGuardrailId(id)} + onSuccess={handleSuccess} /> - )} - + - - - -
+ +
+ )} {/* Test Playground Tab */} diff --git a/ui/litellm-dashboard/src/components/leftnav.tsx b/ui/litellm-dashboard/src/components/leftnav.tsx index 48df5cdde80..9005311052e 100644 --- a/ui/litellm-dashboard/src/components/leftnav.tsx +++ b/ui/litellm-dashboard/src/components/leftnav.tsx @@ -137,7 +137,6 @@ const menuGroups: MenuGroup[] = [ page: "guardrails", label: "Guardrails", icon: , - roles: all_admin_roles, }, { key: "policies", From adb7454f857f39d22442c422a6436f29a912817b Mon Sep 17 00:00:00 2001 From: Ryan Crabbe Date: Thu, 2 Apr 2026 18:08:22 -0700 Subject: [PATCH 4/8] fix: address review feedback on guardrail registration - Make team_id optional again, fall back to API key's team_id (preserves backwards compatibility) - Add PROXY_ADMIN bypass for team membership check (admins can register guardrails for any team) - Move get_team_membership import to module level - Prevent extra_litellm_params spread from overwriting controlled fields (guardrail, mode, api_base) by spreading extras first --- .../proxy/guardrails/guardrail_endpoints.py | 20 ++++++++++--------- .../hooks/guardrails/useRegisterGuardrail.ts | 2 +- .../guardrails/TeamGuardrailsTab.tsx | 2 +- 3 files changed, 13 insertions(+), 11 deletions(-) diff --git a/litellm/proxy/guardrails/guardrail_endpoints.py b/litellm/proxy/guardrails/guardrail_endpoints.py index 2b10537d8e6..b1322d0eefd 100644 --- a/litellm/proxy/guardrails/guardrail_endpoints.py +++ b/litellm/proxy/guardrails/guardrail_endpoints.py @@ -17,6 +17,7 @@ from litellm.constants import DEFAULT_MAX_RECURSE_DEPTH from litellm.integrations.custom_guardrail import CustomGuardrail from litellm.litellm_core_utils.safe_json_dumps import safe_dumps from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth +from litellm.proxy.auth.auth_checks import get_team_membership from litellm.proxy.auth.user_api_key_auth import user_api_key_auth from litellm.proxy.guardrails.guardrail_hooks.custom_code.code_validator import ( CustomCodeValidationError, @@ -542,7 +543,7 @@ class RegisterGuardrailRequest(BaseModel): str, Any ] # guardrail, mode, api_base required; api_key, headers, etc. optional guardrail_info: Optional[Dict[str, Any]] = None - team_id: str + team_id: Optional[str] = None def get_litellm_params_dict(self) -> Dict[str, Any]: return dict(self.litellm_params) @@ -604,28 +605,29 @@ async def register_guardrail( if prisma_client is None: raise HTTPException(status_code=500, detail="Prisma client not initialized") - if not request.team_id: + # Resolve team_id: prefer request body, fall back to API key's team + team_id = request.team_id or user_api_key_dict.team_id + if not team_id: raise HTTPException( status_code=400, - detail="team_id is required.", + detail="team_id is required. Provide it in the request body or use a team-scoped API key.", ) - team_id = request.team_id - # Validate the user is a member of the specified team - if team_id != user_api_key_dict.team_id: - from litellm.proxy.auth.auth_checks import get_team_membership + # Validate team membership for non-admin users when team differs from key + is_admin = user_api_key_dict.user_role == LitellmUserRoles.PROXY_ADMIN + if not is_admin and team_id != user_api_key_dict.team_id: from litellm.proxy.proxy_server import user_api_key_cache membership = await get_team_membership( user_id=user_api_key_dict.user_id or "", - team_id=request.team_id, + team_id=team_id, prisma_client=prisma_client, user_api_key_cache=user_api_key_cache, ) if membership is None: raise HTTPException( status_code=403, - detail=f"You are not a member of team {request.team_id!r}", + detail=f"You are not a member of team {team_id!r}", ) params = request.get_litellm_params_dict() diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/guardrails/useRegisterGuardrail.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/guardrails/useRegisterGuardrail.ts index ccc26c4b17f..3135e8326fc 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/hooks/guardrails/useRegisterGuardrail.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/guardrails/useRegisterGuardrail.ts @@ -14,7 +14,7 @@ export interface RegisterGuardrailParams { guardrail_name: string; litellm_params: Record; guardrail_info?: Record; - team_id: string; + team_id?: string; } export interface RegisterGuardrailResponse { diff --git a/ui/litellm-dashboard/src/components/guardrails/TeamGuardrailsTab.tsx b/ui/litellm-dashboard/src/components/guardrails/TeamGuardrailsTab.tsx index fd3f8b15618..8bd2cdd1964 100644 --- a/ui/litellm-dashboard/src/components/guardrails/TeamGuardrailsTab.tsx +++ b/ui/litellm-dashboard/src/components/guardrails/TeamGuardrailsTab.tsx @@ -1103,10 +1103,10 @@ export function TeamGuardrailsTab({ accessToken }: TeamGuardrailsTabProps) { initialValues={{ mode: "pre_call" }} onFinish={async (values) => { const litellm_params: Record = { + ...(values.extra_litellm_params ? JSON.parse(values.extra_litellm_params) : {}), guardrail: "generic_guardrail_api", mode: values.mode, api_base: values.api_base, - ...(values.extra_litellm_params ? JSON.parse(values.extra_litellm_params) : {}), }; try { await registerGuardrail.mutateAsync({ From a287154905df6812553a983215dbbfd18f489e21 Mon Sep 17 00:00:00 2001 From: Ryan Crabbe Date: Fri, 3 Apr 2026 09:34:29 -0700 Subject: [PATCH 5/8] fix(ui): migrate guardrails tabs to antd and fix internal user view - Replace Tremor TabGroup with antd Tabs (key-based matching) to fix blank content when conditional tabs are hidden for non-admins - Skip /guardrails/submissions fetch for non-admin users via useAuthorized() hook (avoids 401 error) - Move get_team_membership to inline import in register endpoint - Non-admins default to Submitted Guardrails tab with Add Guardrail button visible --- .../proxy/guardrails/guardrail_endpoints.py | 2 +- .../src/components/guardrails.tsx | 242 +++++++++--------- .../guardrails/TeamGuardrailsTab.tsx | 8 +- 3 files changed, 130 insertions(+), 122 deletions(-) diff --git a/litellm/proxy/guardrails/guardrail_endpoints.py b/litellm/proxy/guardrails/guardrail_endpoints.py index b1322d0eefd..b88c6524bb7 100644 --- a/litellm/proxy/guardrails/guardrail_endpoints.py +++ b/litellm/proxy/guardrails/guardrail_endpoints.py @@ -17,7 +17,6 @@ from litellm.constants import DEFAULT_MAX_RECURSE_DEPTH from litellm.integrations.custom_guardrail import CustomGuardrail from litellm.litellm_core_utils.safe_json_dumps import safe_dumps from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth -from litellm.proxy.auth.auth_checks import get_team_membership from litellm.proxy.auth.user_api_key_auth import user_api_key_auth from litellm.proxy.guardrails.guardrail_hooks.custom_code.code_validator import ( CustomCodeValidationError, @@ -616,6 +615,7 @@ async def register_guardrail( # Validate team membership for non-admin users when team differs from key is_admin = user_api_key_dict.user_role == LitellmUserRoles.PROXY_ADMIN if not is_admin and team_id != user_api_key_dict.team_id: + from litellm.proxy.auth.auth_checks import get_team_membership from litellm.proxy.proxy_server import user_api_key_cache membership = await get_team_membership( diff --git a/ui/litellm-dashboard/src/components/guardrails.tsx b/ui/litellm-dashboard/src/components/guardrails.tsx index 6bb4b387a88..d52aba15ab0 100644 --- a/ui/litellm-dashboard/src/components/guardrails.tsx +++ b/ui/litellm-dashboard/src/components/guardrails.tsx @@ -1,6 +1,6 @@ import React, { useState, useEffect } from "react"; -import { Button, TabGroup, TabList, Tab, TabPanels, TabPanel } from "@tremor/react"; -import { Dropdown } from "antd"; +import { Button } from "@tremor/react"; +import { Dropdown, Tabs } from "antd"; import { DownOutlined, PlusOutlined, CodeOutlined } from "@ant-design/icons"; import { getGuardrailsList, deleteGuardrailCall } from "./networking"; import AddGuardrailForm from "./guardrails/add_guardrail_form"; @@ -48,8 +48,6 @@ const GuardrailsPanel: React.FC = ({ accessToken, userRole const [guardrailToDelete, setGuardrailToDelete] = useState(null); const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); const [selectedGuardrailId, setSelectedGuardrailId] = useState(null); - const [activeTab, setActiveTab] = useState(0); - const isAdmin = userRole ? isAdminRole(userRole) : false; const fetchGuardrails = async () => { @@ -135,124 +133,130 @@ const GuardrailsPanel: React.FC = ({ accessToken, userRole return (
- - - {isAdmin && Guardrail Garden} - {isAdmin && Guardrails} - Test Playground - Submitted Guardrails - + + ), + }, + { + key: "guardrails", + label: "Guardrails", + children: ( + <> +
+ , + label: "Add Provider Guardrail", + onClick: handleAddGuardrail, + }, + { + key: "custom_code", + icon: , + label: "Create Custom Code Guardrail", + onClick: handleAddCustomCodeGuardrail, + }, + ], + }} + trigger={["click"]} + disabled={!accessToken} + > + + +
- - {isAdmin && ( - - setSelectedGuardrailId(null)} + accessToken={accessToken} + isAdmin={isAdmin} + /> + ) : ( + setSelectedGuardrailId(id)} + /> + )} + + + + + + + + ), + }, + ] + : []), + { + key: "playground", + label: "Test Playground", + disabled: !accessToken || guardrailsList.length === 0, + children: ( + {}} /> - - )} - - {isAdmin && ( - -
- , - label: "Add Provider Guardrail", - onClick: handleAddGuardrail, - }, - { - key: "custom_code", - icon: , - label: "Create Custom Code Guardrail", - onClick: handleAddCustomCodeGuardrail, - }, - ], - }} - trigger={["click"]} - disabled={!accessToken} - > - - -
- - {selectedGuardrailId ? ( - setSelectedGuardrailId(null)} - accessToken={accessToken} - isAdmin={isAdmin} - /> - ) : ( - setSelectedGuardrailId(id)} - /> - )} - - - - - - -
- )} - - {/* Test Playground Tab */} - - setActiveTab(0)} - /> - - - {/* Team Guardrails Tab */} - - - -
-
+ ), + }, + { + key: "submitted", + label: "Submitted Guardrails", + children: , + }, + ]} + />
); }; diff --git a/ui/litellm-dashboard/src/components/guardrails/TeamGuardrailsTab.tsx b/ui/litellm-dashboard/src/components/guardrails/TeamGuardrailsTab.tsx index 8bd2cdd1964..c3c1836e3c6 100644 --- a/ui/litellm-dashboard/src/components/guardrails/TeamGuardrailsTab.tsx +++ b/ui/litellm-dashboard/src/components/guardrails/TeamGuardrailsTab.tsx @@ -25,6 +25,8 @@ import { import NotificationsManager from "@/components/molecules/notifications_manager"; import TeamDropdown from "@/components/common_components/team_dropdown"; import { useRegisterGuardrail } from "@/app/(dashboard)/hooks/guardrails/useRegisterGuardrail"; +import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; +import { isAdminRole } from "@/utils/roles"; type GuardrailStatus = "active" | "pending" | "rejected"; @@ -803,6 +805,8 @@ interface TeamGuardrailsTabProps { } export function TeamGuardrailsTab({ accessToken }: TeamGuardrailsTabProps) { + const { userRole } = useAuthorized(); + const isAdmin = userRole ? isAdminRole(userRole) : false; const [guardrails, setGuardrails] = useState([]); const [summary, setSummary] = useState({ total: 0, @@ -833,7 +837,7 @@ export function TeamGuardrailsTab({ accessToken }: TeamGuardrailsTabProps) { }, [search]); const fetchSubmissions = useCallback(async () => { - if (!accessToken) { + if (!accessToken || !isAdmin) { setIsLoading(false); return; } @@ -858,7 +862,7 @@ export function TeamGuardrailsTab({ accessToken }: TeamGuardrailsTabProps) { } finally { setIsLoading(false); } - }, [accessToken, statusFilter, searchDebounced]); + }, [accessToken, isAdmin, statusFilter, searchDebounced]); useEffect(() => { fetchSubmissions(); From ab9c875a0019037d7adf59a3e14bb8625faa5eea Mon Sep 17 00:00:00 2001 From: Ryan Crabbe Date: Sat, 4 Apr 2026 15:35:25 -0700 Subject: [PATCH 6/8] feat: scope guardrail submissions to team members Backend: - list_guardrail_submissions no longer 403s non-admins; it returns only submissions whose team_id matches one of the caller's teams (via get_user_object.teams). Admins still see all. - Filtering by a team the caller is not in returns 403. - Users with no team memberships get an empty list (no DB query). - get_guardrail_submission applies the same scoping to single-item GETs. Frontend: - Remove admin-only bail-out in TeamGuardrailsTab.fetchSubmissions so internal users actually load their team's submissions. - Finish antd migration in guardrails.tsx: drop the last Tremor Button. - Remove guardrailsList.length === 0 gate on the Test Playground tab; the playground already renders a "No guardrails available" inline empty state, which is more discoverable than a disabled tab. Tests: - Cover non-admin scoped access, empty teams, cross-team filter 403, and per-submission GET scoping. --- .../proxy/guardrails/guardrail_endpoints.py | 74 +++++++-- .../guardrails/test_guardrail_endpoints.py | 140 +++++++++++++++++- .../src/components/guardrails.tsx | 5 +- .../guardrails/TeamGuardrailsTab.tsx | 8 +- 4 files changed, 203 insertions(+), 24 deletions(-) diff --git a/litellm/proxy/guardrails/guardrail_endpoints.py b/litellm/proxy/guardrails/guardrail_endpoints.py index b88c6524bb7..4aa552a631d 100644 --- a/litellm/proxy/guardrails/guardrail_endpoints.py +++ b/litellm/proxy/guardrails/guardrail_endpoints.py @@ -724,6 +724,30 @@ def _parse_json_field(value: Any) -> Optional[Dict[str, Any]]: return None +async def _get_user_team_ids(user_api_key_dict: UserAPIKeyAuth) -> List[str]: + """Return the list of team_ids the caller belongs to (empty list if none).""" + from litellm.proxy.auth.auth_checks import get_user_object + from litellm.proxy.proxy_server import ( + prisma_client, + proxy_logging_obj, + user_api_key_cache, + ) + + if not user_api_key_dict.user_id or prisma_client is None: + return [] + user_obj = await get_user_object( + user_id=user_api_key_dict.user_id, + prisma_client=prisma_client, + user_api_key_cache=user_api_key_cache, + user_id_upsert=False, + parent_otel_span=user_api_key_dict.parent_otel_span, + proxy_logging_obj=proxy_logging_obj, + ) + if user_obj is None or not user_obj.teams: + return [] + return [t for t in user_obj.teams if t] + + def _row_to_submission_item(row: Any) -> GuardrailSubmissionItem: guardrail_info = _parse_json_field(row.guardrail_info) or {} team_guardrail = row.team_id is not None @@ -756,27 +780,49 @@ async def list_guardrail_submissions( user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), ): """ - List team guardrail submissions (admin only). Returns only guardrails with a team_id. + List team guardrail submissions. Returns only guardrails with a team_id. + + Admins see all submissions. Non-admin users see submissions for teams they are + a member of. Status values: pending_review (team-registered, awaiting approval), active (approved), rejected. Optional filters: - status: pending_review | active | rejected - - team_id: filter by specific team + - team_id: filter by specific team (non-admins must be a member of that team) - search: name/description """ from litellm.proxy.proxy_server import prisma_client - if user_api_key_dict.user_role != LitellmUserRoles.PROXY_ADMIN: - raise HTTPException(status_code=403, detail="Admin access required") - if prisma_client is None: raise HTTPException(status_code=500, detail="Prisma client not initialized") + is_admin = user_api_key_dict.user_role == LitellmUserRoles.PROXY_ADMIN + visible_team_ids: Optional[List[str]] = None + if not is_admin: + visible_team_ids = await _get_user_team_ids(user_api_key_dict) + if team_id is not None and team_id not in visible_team_ids: + raise HTTPException( + status_code=403, + detail=f"You are not a member of team {team_id!r}", + ) + try: - # Single query: fetch all team guardrails (team_id is not null) + where_clause: Dict[str, Any] = {"team_id": {"not": None}} + if visible_team_ids is not None: + if not visible_team_ids: + # Non-admin with no team memberships: nothing visible. + return ListGuardrailSubmissionsResponse( + submissions=[], + summary=GuardrailSubmissionSummary( + total=0, pending_review=0, active=0, rejected=0 + ), + ) + where_clause["team_id"] = {"in": visible_team_ids} + + # Single query: fetch team guardrails visible to the caller all_team_rows = await prisma_client.db.litellm_guardrailstable.find_many( - where={"team_id": {"not": None}}, + where=where_clause, order={"created_at": "desc"}, ) @@ -837,15 +883,14 @@ async def get_guardrail_submission( guardrail_id: str, user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), ): - """Get a single guardrail submission by id (admin only).""" + """Get a single guardrail submission by id. Non-admins may only access submissions for teams they belong to.""" from litellm.proxy.proxy_server import prisma_client - if user_api_key_dict.user_role != LitellmUserRoles.PROXY_ADMIN: - raise HTTPException(status_code=403, detail="Admin access required") - if prisma_client is None: raise HTTPException(status_code=500, detail="Prisma client not initialized") + is_admin = user_api_key_dict.user_role == LitellmUserRoles.PROXY_ADMIN + try: row = await prisma_client.db.litellm_guardrailstable.find_unique( where={"guardrail_id": guardrail_id} @@ -854,6 +899,13 @@ async def get_guardrail_submission( raise HTTPException( status_code=404, detail="Guardrail submission not found" ) + if not is_admin: + visible_team_ids = await _get_user_team_ids(user_api_key_dict) + if row.team_id is None or row.team_id not in visible_team_ids: + raise HTTPException( + status_code=403, + detail="You are not a member of the team that owns this submission", + ) return _row_to_submission_item(row) except HTTPException: raise diff --git a/tests/test_litellm/proxy/guardrails/test_guardrail_endpoints.py b/tests/test_litellm/proxy/guardrails/test_guardrail_endpoints.py index ca224726361..244150f8554 100644 --- a/tests/test_litellm/proxy/guardrails/test_guardrail_endpoints.py +++ b/tests/test_litellm/proxy/guardrails/test_guardrail_endpoints.py @@ -1237,13 +1237,82 @@ async def test_register_guardrail_duplicate_name(mocker): @pytest.mark.asyncio -async def test_list_guardrail_submissions_requires_admin(mocker): - """List submissions returns 403 when user is not admin.""" +async def test_list_guardrail_submissions_non_admin_scoped_to_own_teams(mocker): + """Non-admin callers see only submissions for teams they belong to.""" + mock_prisma = mocker.Mock() + own_team_row = mocker.Mock( + guardrail_id="mine", + guardrail_name="mine-guard", + status="pending_review", + team_id="team-mine", + litellm_params={}, + guardrail_info={}, + submitted_at=None, + reviewed_at=None, + created_at=datetime.now(), + updated_at=datetime.now(), + ) + find_many = AsyncMock(return_value=[own_team_row]) + mock_prisma.db.litellm_guardrailstable.find_many = find_many + mocker.patch("litellm.proxy.proxy_server.prisma_client", mock_prisma) + mocker.patch( + "litellm.proxy.guardrails.guardrail_endpoints._get_user_team_ids", + AsyncMock(return_value=["team-mine"]), + ) + user = UserAPIKeyAuth( + user_id="u1", user_role=LitellmUserRoles.INTERNAL_USER + ) + + result = await list_guardrail_submissions(user_api_key_dict=user) + + # DB query scoped to visible teams + where_clause = find_many.call_args.kwargs["where"] + assert where_clause["team_id"] == {"in": ["team-mine"]} + assert len(result.submissions) == 1 + assert result.submissions[0].team_id == "team-mine" + # Summary counts reflect only visible teams + assert result.summary.total == 1 + assert result.summary.pending_review == 1 + + +@pytest.mark.asyncio +async def test_list_guardrail_submissions_non_admin_no_teams(mocker): + """Non-admin caller with no team memberships gets an empty list (not 403).""" + mock_prisma = mocker.Mock() + find_many = AsyncMock(return_value=[]) + mock_prisma.db.litellm_guardrailstable.find_many = find_many + mocker.patch("litellm.proxy.proxy_server.prisma_client", mock_prisma) + mocker.patch( + "litellm.proxy.guardrails.guardrail_endpoints._get_user_team_ids", + AsyncMock(return_value=[]), + ) + user = UserAPIKeyAuth( + user_id="u1", user_role=LitellmUserRoles.INTERNAL_USER + ) + + result = await list_guardrail_submissions(user_api_key_dict=user) + + assert result.submissions == [] + assert result.summary.total == 0 + assert find_many.call_count == 0 # no DB query when user has no teams + + +@pytest.mark.asyncio +async def test_list_guardrail_submissions_non_admin_team_filter_forbidden(mocker): + """Non-admin caller filtering by a team they're not in gets 403.""" mocker.patch("litellm.proxy.proxy_server.prisma_client", mocker.Mock()) - user = UserAPIKeyAuth(user_role=LitellmUserRoles.INTERNAL_USER) + mocker.patch( + "litellm.proxy.guardrails.guardrail_endpoints._get_user_team_ids", + AsyncMock(return_value=["team-mine"]), + ) + user = UserAPIKeyAuth( + user_id="u1", user_role=LitellmUserRoles.INTERNAL_USER + ) with pytest.raises(HTTPException) as exc_info: - await list_guardrail_submissions(user_api_key_dict=user) + await list_guardrail_submissions( + team_id="team-other", user_api_key_dict=user + ) assert exc_info.value.status_code == 403 @@ -1354,6 +1423,69 @@ async def test_get_guardrail_submission_not_found(mocker): assert exc_info.value.status_code == 404 +@pytest.mark.asyncio +async def test_get_guardrail_submission_non_admin_own_team(mocker): + """Non-admin caller can fetch a submission belonging to one of their teams.""" + mock_prisma = mocker.Mock() + row = mocker.Mock( + guardrail_id="sub-1", + guardrail_name="team-guard", + status="pending_review", + team_id="team-mine", + litellm_params={}, + guardrail_info={}, + submitted_at=None, + reviewed_at=None, + created_at=datetime.now(), + updated_at=datetime.now(), + ) + mock_prisma.db.litellm_guardrailstable.find_unique = AsyncMock(return_value=row) + mocker.patch("litellm.proxy.proxy_server.prisma_client", mock_prisma) + mocker.patch( + "litellm.proxy.guardrails.guardrail_endpoints._get_user_team_ids", + AsyncMock(return_value=["team-mine"]), + ) + user = UserAPIKeyAuth( + user_id="u1", user_role=LitellmUserRoles.INTERNAL_USER + ) + + result = await get_guardrail_submission("sub-1", user) + + assert result.guardrail_id == "sub-1" + assert result.team_id == "team-mine" + + +@pytest.mark.asyncio +async def test_get_guardrail_submission_non_admin_other_team_forbidden(mocker): + """Non-admin caller gets 403 when fetching a submission for a team they're not in.""" + mock_prisma = mocker.Mock() + row = mocker.Mock( + guardrail_id="sub-1", + guardrail_name="team-guard", + status="pending_review", + team_id="team-other", + litellm_params={}, + guardrail_info={}, + submitted_at=None, + reviewed_at=None, + created_at=datetime.now(), + updated_at=datetime.now(), + ) + mock_prisma.db.litellm_guardrailstable.find_unique = AsyncMock(return_value=row) + mocker.patch("litellm.proxy.proxy_server.prisma_client", mock_prisma) + mocker.patch( + "litellm.proxy.guardrails.guardrail_endpoints._get_user_team_ids", + AsyncMock(return_value=["team-mine"]), + ) + user = UserAPIKeyAuth( + user_id="u1", user_role=LitellmUserRoles.INTERNAL_USER + ) + + with pytest.raises(HTTPException) as exc_info: + await get_guardrail_submission("sub-1", user) + assert exc_info.value.status_code == 403 + + @pytest.mark.asyncio async def test_approve_guardrail_submission_success(mocker): """Approve sets status to active and initializes guardrail in memory.""" diff --git a/ui/litellm-dashboard/src/components/guardrails.tsx b/ui/litellm-dashboard/src/components/guardrails.tsx index d52aba15ab0..a4383e1648f 100644 --- a/ui/litellm-dashboard/src/components/guardrails.tsx +++ b/ui/litellm-dashboard/src/components/guardrails.tsx @@ -1,6 +1,5 @@ import React, { useState, useEffect } from "react"; -import { Button } from "@tremor/react"; -import { Dropdown, Tabs } from "antd"; +import { Button, Dropdown, Tabs } from "antd"; import { DownOutlined, PlusOutlined, CodeOutlined } from "@ant-design/icons"; import { getGuardrailsList, deleteGuardrailCall } from "./networking"; import AddGuardrailForm from "./guardrails/add_guardrail_form"; @@ -240,7 +239,7 @@ const GuardrailsPanel: React.FC = ({ accessToken, userRole { key: "playground", label: "Test Playground", - disabled: !accessToken || guardrailsList.length === 0, + disabled: !accessToken, children: ( ([]); const [summary, setSummary] = useState({ total: 0, @@ -837,7 +833,7 @@ export function TeamGuardrailsTab({ accessToken }: TeamGuardrailsTabProps) { }, [search]); const fetchSubmissions = useCallback(async () => { - if (!accessToken || !isAdmin) { + if (!accessToken) { setIsLoading(false); return; } @@ -862,7 +858,7 @@ export function TeamGuardrailsTab({ accessToken }: TeamGuardrailsTabProps) { } finally { setIsLoading(false); } - }, [accessToken, isAdmin, statusFilter, searchDebounced]); + }, [accessToken, statusFilter, searchDebounced]); useEffect(() => { fetchSubmissions(); From 79064a68e4161c95c043eddcebde308b54a172ef Mon Sep 17 00:00:00 2001 From: Ryan Crabbe Date: Sat, 4 Apr 2026 16:11:28 -0700 Subject: [PATCH 7/8] fix: allow non-admin access to /guardrails/submissions and fix register membership check Bug 1: internal users hit route-level 403 on /guardrails/submissions. The route wasn't in self_managed_routes, so the route allowlist rejected non-admin callers before our endpoint's team-scoping ran. Added /guardrails/submissions and /guardrails/submissions/{guardrail_id} to self_managed_routes. Bug 2: register_guardrail 403'd non-admins registering for teams in their user.teams list. It used get_team_membership() which reads the litellm_teammembership join table, but that row is only created when the team has a budget (management_helpers/utils.py:225). Switched to the _get_user_team_ids helper (reads user_obj.teams), making it consistent with list_guardrail_submissions. UI: moved the Test Playground tab inside the isAdmin conditional in guardrails.tsx. Internal users now see only the Submitted Guardrails tab; admins still see all four. Tests: added coverage for non-admin register paths (cross-team allowed and cross-team forbidden). --- litellm/proxy/_types.py | 3 ++ .../proxy/guardrails/guardrail_endpoints.py | 12 +---- .../guardrails/test_guardrail_endpoints.py | 53 +++++++++++++++++++ .../src/components/guardrails.tsx | 26 ++++----- 4 files changed, 71 insertions(+), 23 deletions(-) diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py index 8faf36df4c6..c19e4c9f39f 100644 --- a/litellm/proxy/_types.py +++ b/litellm/proxy/_types.py @@ -665,6 +665,9 @@ class LiteLLMRoutes(enum.Enum): "/invitation/delete", # Team guardrail submission - requires team-scoped key; endpoint enforces team_id "/guardrails/register", + # Team guardrail submissions - endpoint scopes results to caller's teams (non-admin) + "/guardrails/submissions", + "/guardrails/submissions/{guardrail_id}", ] # routes that manage their own allowed/disallowed logic ## Org Admin Routes ## diff --git a/litellm/proxy/guardrails/guardrail_endpoints.py b/litellm/proxy/guardrails/guardrail_endpoints.py index 4aa552a631d..422bdc13780 100644 --- a/litellm/proxy/guardrails/guardrail_endpoints.py +++ b/litellm/proxy/guardrails/guardrail_endpoints.py @@ -615,16 +615,8 @@ async def register_guardrail( # Validate team membership for non-admin users when team differs from key is_admin = user_api_key_dict.user_role == LitellmUserRoles.PROXY_ADMIN if not is_admin and team_id != user_api_key_dict.team_id: - from litellm.proxy.auth.auth_checks import get_team_membership - from litellm.proxy.proxy_server import user_api_key_cache - - membership = await get_team_membership( - user_id=user_api_key_dict.user_id or "", - team_id=team_id, - prisma_client=prisma_client, - user_api_key_cache=user_api_key_cache, - ) - if membership is None: + user_team_ids = await _get_user_team_ids(user_api_key_dict) + if team_id not in user_team_ids: raise HTTPException( status_code=403, detail=f"You are not a member of team {team_id!r}", diff --git a/tests/test_litellm/proxy/guardrails/test_guardrail_endpoints.py b/tests/test_litellm/proxy/guardrails/test_guardrail_endpoints.py index 244150f8554..defea08594f 100644 --- a/tests/test_litellm/proxy/guardrails/test_guardrail_endpoints.py +++ b/tests/test_litellm/proxy/guardrails/test_guardrail_endpoints.py @@ -1220,6 +1220,59 @@ async def test_register_guardrail_requires_team_id(mocker): assert "team" in exc_info.value.detail.lower() +@pytest.mark.asyncio +async def test_register_guardrail_non_admin_cross_team_allowed(mocker): + """Non-admin may register for a team in their user.teams list even if the key's team_id differs.""" + mock_prisma = mocker.Mock() + mock_prisma.db.litellm_guardrailstable.find_unique = AsyncMock(return_value=None) + created = mocker.Mock( + guardrail_id="g1", + guardrail_name=MOCK_REGISTER_REQUEST.guardrail_name, + status="pending_review", + submitted_at=datetime.now(), + ) + mock_prisma.db.litellm_guardrailstable.create = AsyncMock(return_value=created) + mocker.patch("litellm.proxy.proxy_server.prisma_client", mock_prisma) + mocker.patch( + "litellm.proxy.guardrails.guardrail_endpoints._get_user_team_ids", + AsyncMock(return_value=["team-alpha", "team-beta"]), + ) + req = RegisterGuardrailRequest( + guardrail_name=MOCK_REGISTER_REQUEST.guardrail_name, + team_id="team-beta", + litellm_params=MOCK_REGISTER_REQUEST.litellm_params, + ) + user = UserAPIKeyAuth( + user_id="u1", user_role=LitellmUserRoles.INTERNAL_USER, team_id="team-alpha" + ) + + result = await register_guardrail(req, user) + + assert result.guardrail_id == "g1" + + +@pytest.mark.asyncio +async def test_register_guardrail_non_admin_cross_team_forbidden(mocker): + """Non-admin gets 403 when registering for a team they are not a member of.""" + mocker.patch("litellm.proxy.proxy_server.prisma_client", mocker.Mock()) + mocker.patch( + "litellm.proxy.guardrails.guardrail_endpoints._get_user_team_ids", + AsyncMock(return_value=["team-alpha"]), + ) + req = RegisterGuardrailRequest( + guardrail_name=MOCK_REGISTER_REQUEST.guardrail_name, + team_id="team-other", + litellm_params=MOCK_REGISTER_REQUEST.litellm_params, + ) + user = UserAPIKeyAuth( + user_id="u1", user_role=LitellmUserRoles.INTERNAL_USER, team_id="team-alpha" + ) + + with pytest.raises(HTTPException) as exc_info: + await register_guardrail(req, user) + assert exc_info.value.status_code == 403 + + @pytest.mark.asyncio async def test_register_guardrail_duplicate_name(mocker): """Register returns 400 when guardrail_name already exists.""" diff --git a/ui/litellm-dashboard/src/components/guardrails.tsx b/ui/litellm-dashboard/src/components/guardrails.tsx index a4383e1648f..fee9d02d3ae 100644 --- a/ui/litellm-dashboard/src/components/guardrails.tsx +++ b/ui/litellm-dashboard/src/components/guardrails.tsx @@ -234,21 +234,21 @@ const GuardrailsPanel: React.FC = ({ accessToken, userRole ), }, + { + key: "playground", + label: "Test Playground", + disabled: !accessToken, + children: ( + {}} + /> + ), + }, ] : []), - { - key: "playground", - label: "Test Playground", - disabled: !accessToken, - children: ( - {}} - /> - ), - }, { key: "submitted", label: "Submitted Guardrails", From a36fe70fde0bc6dddfb9504ba623d220b01e6713 Mon Sep 17 00:00:00 2001 From: Ryan Crabbe Date: Sat, 4 Apr 2026 16:32:55 -0700 Subject: [PATCH 8/8] test(ui): fix guardrails.test.tsx after antd Tabs migration The test was hitting "No QueryClient set" because TeamGuardrailsTab (which pulls in useRegisterGuardrail) was not mocked alongside the other tab children. Added a mock. Also: the "+ Add New Guardrail" assertion was silently relying on Tremor Tabs rendering all panels at once. antd Tabs only renders the active tab's content, and defaultActiveKey is "submitted", so the button in the "Guardrails" tab wasn't in the DOM. Clicking the Guardrails tab first before asserting. --- ui/litellm-dashboard/src/components/guardrails.test.tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/ui/litellm-dashboard/src/components/guardrails.test.tsx b/ui/litellm-dashboard/src/components/guardrails.test.tsx index 8cafc18eb9a..99c2474347e 100644 --- a/ui/litellm-dashboard/src/components/guardrails.test.tsx +++ b/ui/litellm-dashboard/src/components/guardrails.test.tsx @@ -1,4 +1,4 @@ -import { render, screen } from "@testing-library/react"; +import { render, screen, fireEvent } from "@testing-library/react"; import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import GuardrailsPanel from "./guardrails"; import { getGuardrailsList } from "./networking"; @@ -40,6 +40,10 @@ vi.mock("./guardrails/GuardrailTestPlayground", () => ({ default: () =>
Mock Guardrail Test Playground
, })); +vi.mock("./guardrails/TeamGuardrailsTab", () => ({ + TeamGuardrailsTab: () =>
Mock Team Guardrails Tab
, +})); + vi.mock("@/utils/roles", () => ({ isAdminRole: vi.fn((role: string) => role === "admin"), })); @@ -99,6 +103,8 @@ describe("GuardrailsPanel", () => { it("should render the component", async () => { render(); expect(screen.getByText("Guardrails")).toBeInTheDocument(); + // Activate the Guardrails tab so its content (including the Add button) is rendered + fireEvent.click(screen.getByText("Guardrails")); expect(screen.getByText("+ Add New Guardrail")).toBeInTheDocument(); }); });