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:
Ryan Crabbe 2026-04-04 15:35:25 -07:00
parent a287154905
commit ab9c875a00
No known key found for this signature in database
4 changed files with 203 additions and 24 deletions

View file

@ -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

View file

@ -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."""

View file

@ -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}

View file

@ -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();