fix: allow team admin to edit guardrails they create

This commit is contained in:
Krrish Dholakia 2026-02-26 20:02:09 -08:00
parent 94c8705d0c
commit 3061bd71c9
4 changed files with 101 additions and 5 deletions

View file

@ -0,0 +1,9 @@
-- AlterTable
ALTER TABLE "LiteLLM_DeletedVerificationToken" DROP COLUMN "agent_id";
-- AlterTable
ALTER TABLE "LiteLLM_ObjectPermissionTable" DROP COLUMN "blocked_tools";
-- DropTable
DROP TABLE "LiteLLM_SpendLogToolIndex";

View file

@ -936,6 +936,7 @@ async def get_guardrail_info(
guardrail_name=result.get("guardrail_name"),
litellm_params=masked_litellm_params,
guardrail_info=dict(result.get("guardrail_info") or {}),
team_id=result.get("team_id"),
created_at=result.get("created_at"),
updated_at=result.get("updated_at"),
guardrail_definition_location=guardrail_definition_location,

View file

@ -1,8 +1,18 @@
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import { useTeams } from "@/app/(dashboard)/hooks/teams/useTeams";
import * as networking from "@/components/networking";
import { fireEvent, render, waitFor } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import GuardrailInfoView from "./guardrail_info";
// Mock useAuthorized and useTeams so we don't need QueryClientProvider / auth context
vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({
default: vi.fn(),
}));
vi.mock("@/app/(dashboard)/hooks/teams/useTeams", () => ({
useTeams: vi.fn(),
}));
// Mock the networking module
vi.mock("@/components/networking", () => ({
getGuardrailInfo: vi.fn(),
@ -35,6 +45,11 @@ vi.mock("./content_filter/ContentFilterManager", () => ({
}));
describe("Guardrail Info", () => {
beforeEach(() => {
vi.mocked(useAuthorized).mockReturnValue({ userId: "test-user" } as any);
vi.mocked(useTeams).mockReturnValue({ data: [] } as any);
});
afterEach(() => {
vi.clearAllMocks();
});
@ -267,4 +282,62 @@ describe("Guardrail Info", () => {
expect(secondCallArgs.litellm_params.patterns).toEqual(["new_pattern"]);
expect(secondCallArgs.litellm_params.blocked_words).toEqual(["new_word"]);
});
it("should show the Settings tab and Edit Settings button when user is team admin for the guardrail's team", async () => {
const teamAdminUserId = "team-admin-user";
vi.mocked(useAuthorized).mockReturnValue({ userId: teamAdminUserId } as any);
vi.mocked(useTeams).mockReturnValue({
data: [
{
team_id: "team-1",
team_alias: "Team 1",
models: [],
max_budget: null,
budget_duration: null,
members_with_roles: [
{ user_id: teamAdminUserId, user_email: "admin@test.com", role: "admin" },
],
spend: 0,
},
],
} as any);
vi.mocked(networking.getGuardrailInfo).mockResolvedValue({
guardrail_id: "123",
guardrail_name: "Team Guardrail",
team_id: "team-1",
litellm_params: {
guardrail: "presidio",
mode: "pre_call",
default_on: true,
},
created_at: "2024-01-01T00:00:00Z",
updated_at: "2024-01-01T00:00:00Z",
guardrail_definition_location: "database",
});
vi.mocked(networking.getGuardrailUISettings).mockResolvedValue({
supported_entities: ["PERSON", "EMAIL"],
supported_actions: ["MASK", "REDACT"],
pii_entity_categories: [],
supported_modes: ["pre_call", "post_call"],
});
vi.mocked(networking.getGuardrailProviderSpecificParams).mockResolvedValue({});
const { getByText } = render(
<GuardrailInfoView guardrailId="123" onClose={() => {}} accessToken="123" isAdmin={false} />,
);
await waitFor(() => {
expect(getByText("Settings")).toBeInTheDocument();
});
fireEvent.click(getByText("Settings"));
await waitFor(() => {
expect(getByText("Guardrail Settings")).toBeInTheDocument();
});
expect(getByText("Edit Settings")).toBeInTheDocument();
});
});

View file

@ -1,3 +1,5 @@
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import { useTeams } from "@/app/(dashboard)/hooks/teams/useTeams";
import {
getGuardrailInfo,
getGuardrailProviderSpecificParams,
@ -5,6 +7,7 @@ import {
updateGuardrailCall,
} from "@/components/networking";
import { copyToClipboard as utilCopyToClipboard } from "@/utils/dataUtils";
import { isUserTeamAdminForSingleTeam } from "@/utils/roles";
import { CodeOutlined, EyeInvisibleOutlined, InfoCircleOutlined, StopOutlined } from "@ant-design/icons";
import { ArrowLeftIcon } from "@heroicons/react/outline";
import {
@ -57,6 +60,8 @@ interface ProviderParamsResponse {
}
const GuardrailInfoView: React.FC<GuardrailInfoProps> = ({ guardrailId, onClose, accessToken, isAdmin }) => {
const { userId } = useAuthorized();
const { data: teams } = useTeams();
const [guardrailData, setGuardrailData] = useState<any>(null);
const [guardrailProviderSpecificParams, setGuardrailProviderSpecificParams] = useState<any>(null);
const [loading, setLoading] = useState(true);
@ -463,6 +468,14 @@ const GuardrailInfoView: React.FC<GuardrailInfoProps> = ({ guardrailId, onClose,
};
const isConfigGuardrail = guardrailData.guardrail_definition_location === "config";
const canEditGuardrail =
isAdmin ||
(guardrailData?.team_id &&
teams &&
isUserTeamAdminForSingleTeam(
teams.find((t) => t.team_id === guardrailData.team_id)?.members_with_roles ?? null,
userId ?? "",
));
return (
<div className="p-4">
@ -490,7 +503,7 @@ const GuardrailInfoView: React.FC<GuardrailInfoProps> = ({ guardrailId, onClose,
<TabGroup>
<TabList className="mb-4">
<Tab key="overview">Overview</Tab>
{isAdmin ? <Tab key="settings">Settings</Tab> : <></>}
{canEditGuardrail ? <Tab key="settings">Settings</Tab> : <></>}
</TabList>
<TabPanels>
@ -589,7 +602,7 @@ const GuardrailInfoView: React.FC<GuardrailInfoProps> = ({ guardrailId, onClose,
<CodeOutlined className="text-blue-500" />
<Text className="font-medium text-lg">Custom Code</Text>
</div>
{isAdmin && !isConfigGuardrail && (
{canEditGuardrail && !isConfigGuardrail && (
<Button
size="small"
icon={<CodeOutlined />}
@ -616,8 +629,8 @@ const GuardrailInfoView: React.FC<GuardrailInfoProps> = ({ guardrailId, onClose,
/>
</TabPanel>
{/* Settings Panel (only for admins) */}
{isAdmin && (
{/* Settings Panel (only for admins or team admins for this guardrail's team) */}
{canEditGuardrail && (
<TabPanel>
<Card>
<div className="flex justify-between items-center mb-4">