mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-14 23:21:35 +00:00
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.
This commit is contained in:
parent
a287154905
commit
ab9c875a00
4 changed files with 203 additions and 24 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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<GuardrailsPanelProps> = ({ accessToken, userRole
|
|||
{
|
||||
key: "playground",
|
||||
label: "Test Playground",
|
||||
disabled: !accessToken || guardrailsList.length === 0,
|
||||
disabled: !accessToken,
|
||||
children: (
|
||||
<GuardrailTestPlayground
|
||||
guardrailsList={guardrailsList}
|
||||
|
|
|
|||
|
|
@ -25,8 +25,6 @@ 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";
|
||||
|
||||
|
|
@ -805,8 +803,6 @@ interface TeamGuardrailsTabProps {
|
|||
}
|
||||
|
||||
export function TeamGuardrailsTab({ accessToken }: TeamGuardrailsTabProps) {
|
||||
const { userRole } = useAuthorized();
|
||||
const isAdmin = userRole ? isAdminRole(userRole) : false;
|
||||
const [guardrails, setGuardrails] = useState<TeamGuardrail[]>([]);
|
||||
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();
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue