mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-06 02:48:13 +00:00
Merge 9a1a85e83f into 88f3d22eca
This commit is contained in:
commit
0143a68c79
9 changed files with 376 additions and 62 deletions
|
|
@ -10,11 +10,14 @@ const identity = {
|
|||
provider: "microsoft_entra",
|
||||
tenant_id: "11111111-1111-4111-8111-111111111111",
|
||||
client_id: "22222222-2222-4222-8222-222222222222",
|
||||
provisioning_source_id: "directory",
|
||||
};
|
||||
|
||||
const status = {
|
||||
enabled: true,
|
||||
execution_mode: "autonomous",
|
||||
directory_active: true,
|
||||
directory_access_group_ids: ["read"],
|
||||
last_authenticated_at: "2026-09-24T12:00:00Z",
|
||||
};
|
||||
|
||||
|
|
@ -24,15 +27,24 @@ describe("agent identity evidence", () => {
|
|||
testQueryClient.clear();
|
||||
});
|
||||
|
||||
it("shows persisted application identity evidence and links to the current logs route", async () => {
|
||||
it("shows persisted native identity evidence and links to the current logs route", async () => {
|
||||
vi.mocked(apiClient.get).mockResolvedValue(status);
|
||||
renderWithProviders(<AgentIdentityDetails agentId="native" identity={identity} accessToken="admin" isAdmin />);
|
||||
expect(await screen.findByText(/Last authenticated identity match:/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Application \(Client\) ID:/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Entra Parent Identity ID:/)).toBeInTheDocument();
|
||||
expect(screen.getByText("Directory status: Active")).toBeInTheDocument();
|
||||
expect(screen.getByText("Mapped access groups: 1")).toBeInTheDocument();
|
||||
expect(screen.getByRole("link", { name: "View request logs" })).toHaveAttribute("href", "/ui/logs/");
|
||||
expect(apiClient.get).toHaveBeenCalledWith("/v1/agents/native/identity", { accessToken: "admin" });
|
||||
});
|
||||
|
||||
it("does not present unavailable directory status as active", () => {
|
||||
vi.mocked(apiClient.get).mockReturnValue(new Promise(() => {}));
|
||||
renderWithProviders(<AgentIdentityDetails agentId="native" identity={identity} accessToken="admin" isAdmin />);
|
||||
expect(screen.getByText("Directory status: Unavailable")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Directory status: Active")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not request or show administrator identity evidence to ordinary users", () => {
|
||||
renderWithProviders(
|
||||
<AgentIdentityDetails agentId="native" identity={identity} accessToken="user" isAdmin={false} />,
|
||||
|
|
|
|||
|
|
@ -11,6 +11,11 @@ const authenticationMessage = (error: boolean, lastAuthenticated?: string | null
|
|||
return "Configured, awaiting an authenticated request";
|
||||
};
|
||||
|
||||
const directoryStatus = (active?: boolean | null): string => {
|
||||
if (active == null) return "Unavailable";
|
||||
return active ? "Active" : "Inactive";
|
||||
};
|
||||
|
||||
export const AgentIdentityDetails = ({
|
||||
agentId,
|
||||
identity: value,
|
||||
|
|
@ -43,12 +48,25 @@ export const AgentIdentityDetails = ({
|
|||
<p className="text-sm">
|
||||
Tenant: <span className="font-mono">{identity.tenant_id}</span>
|
||||
</p>
|
||||
<>
|
||||
<p className="text-sm">
|
||||
Application (Client) ID: <span className="font-mono">{identity.client_id}</span>
|
||||
</p>
|
||||
<p className="text-sm">Enterprise application Object ID: {identity.service_principal_id || "Not configured"}</p>
|
||||
</>
|
||||
{identity.provisioning_source_id ? (
|
||||
<>
|
||||
<p className="text-sm">
|
||||
Entra Parent Identity ID: <span className="font-mono">{identity.client_id}</span>
|
||||
</p>
|
||||
<p className="text-sm">Provisioned through Entra SCIM</p>
|
||||
<p className="text-sm">Directory status: {directoryStatus(data?.directory_active)}</p>
|
||||
<p className="text-sm">Mapped access groups: {data?.directory_access_group_ids?.length ?? 0}</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-sm">
|
||||
Application (Client) ID: <span className="font-mono">{identity.client_id}</span>
|
||||
</p>
|
||||
<p className="text-sm">
|
||||
Enterprise application Object ID: {identity.service_principal_id || "Not configured"}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
<p className="text-sm">
|
||||
Execution: {data ? executionLabel : "Loading"} · Mode: {data?.execution_mode ?? "Loading"}
|
||||
</p>
|
||||
|
|
|
|||
|
|
@ -22,8 +22,9 @@ const EXECUTION_OPTIONS = [
|
|||
|
||||
export const AgentIdentityFields = ({ accessToken }: { accessToken: string | null }) => {
|
||||
const provider = useWatch<AgentFormValues>({ name: "identity_provider" });
|
||||
const provisioningSource = useWatch<AgentFormValues>({ name: "identity_provisioning_source_id" });
|
||||
const mode = useWatch<AgentFormValues>({ name: "execution_mode" });
|
||||
const showScopes = mode !== "autonomous" && mode !== undefined;
|
||||
const showScopes = Boolean(provisioningSource) || (mode !== "autonomous" && mode !== undefined);
|
||||
const [tenants, setTenants] = useState<string[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
|
|
@ -64,6 +65,7 @@ export const AgentIdentityFields = ({ accessToken }: { accessToken: string | nul
|
|||
<AgentFormField name="identity_provider" label="Identity Provider" defaultValue="none">
|
||||
{({ value, onChange, id }) => (
|
||||
<Select
|
||||
disabled={Boolean(provisioningSource)}
|
||||
items={PROVIDER_OPTIONS}
|
||||
value={typeof value === "string" ? value : "none"}
|
||||
onValueChange={onChange}
|
||||
|
|
@ -89,7 +91,11 @@ export const AgentIdentityFields = ({ accessToken }: { accessToken: string | nul
|
|||
rules={{ required: "Select a trusted tenant" }}
|
||||
>
|
||||
{({ value, onChange, id }) => (
|
||||
<Select value={typeof value === "string" ? value : ""} onValueChange={onChange}>
|
||||
<Select
|
||||
disabled={Boolean(provisioningSource)}
|
||||
value={typeof value === "string" ? value : ""}
|
||||
onValueChange={onChange}
|
||||
>
|
||||
<SelectTrigger id={id}>
|
||||
<SelectValue placeholder="Select the gateway's trusted tenant" />
|
||||
</SelectTrigger>
|
||||
|
|
@ -116,7 +122,7 @@ export const AgentIdentityFields = ({ accessToken }: { accessToken: string | nul
|
|||
)}
|
||||
<AgentFormField
|
||||
name="identity_client_id"
|
||||
label="Application (Client) ID"
|
||||
label={provisioningSource ? "Entra Parent Identity ID" : "Application (Client) ID"}
|
||||
rules={{
|
||||
required: "Enter the Entra application client ID",
|
||||
pattern: { value: IDENTITY_UUID_PATTERN, message: "Enter a valid application client UUID" },
|
||||
|
|
@ -137,13 +143,21 @@ export const AgentIdentityFields = ({ accessToken }: { accessToken: string | nul
|
|||
ref={ref}
|
||||
value={typeof value === "string" ? value : ""}
|
||||
onChange={onChange}
|
||||
disabled={Boolean(provisioningSource)}
|
||||
placeholder="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
|
||||
/>
|
||||
)}
|
||||
</AgentFormField>
|
||||
{provisioningSource && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Provisioned Entra agent-user. Identity fields are owned by your directory. Configure permissions and
|
||||
enable this agent when ready.
|
||||
</p>
|
||||
)}
|
||||
<AgentFormField name="execution_mode" label="Execution Mode" defaultValue="autonomous">
|
||||
{({ value, onChange, id }) => (
|
||||
<Select
|
||||
disabled={Boolean(provisioningSource)}
|
||||
items={EXECUTION_MODE_OPTIONS}
|
||||
value={typeof value === "string" ? value : "autonomous"}
|
||||
onValueChange={onChange}
|
||||
|
|
@ -161,55 +175,57 @@ export const AgentIdentityFields = ({ accessToken }: { accessToken: string | nul
|
|||
</Select>
|
||||
)}
|
||||
</AgentFormField>
|
||||
<AgentFormField
|
||||
name="identity_service_principal_id"
|
||||
label="Enterprise Application Object ID"
|
||||
rules={{
|
||||
required: mode !== "delegated" ? "Enter the service principal Object ID" : false,
|
||||
pattern: { value: IDENTITY_UUID_PATTERN, message: "Enter a valid service principal UUID" },
|
||||
}}
|
||||
description={
|
||||
<>
|
||||
Open{" "}
|
||||
<a
|
||||
className="underline"
|
||||
href="https://entra.microsoft.com/#view/Microsoft_AAD_IAM/StartboardApplicationsMenuBlade/~/AppAppsPreview"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
Entra Enterprise applications
|
||||
</a>
|
||||
, select this application, and copy its Object ID. The App registrations Object ID is a different
|
||||
value.
|
||||
</>
|
||||
}
|
||||
>
|
||||
{({ value, onChange, ref, ...control }) => (
|
||||
<Input {...control} ref={ref} value={typeof value === "string" ? value : ""} onChange={onChange} />
|
||||
)}
|
||||
</AgentFormField>
|
||||
|
||||
<AgentFormField
|
||||
name="identity_required_roles"
|
||||
label="Required Application Roles"
|
||||
description="Comma-separated role values required on autonomous application tokens"
|
||||
>
|
||||
{({ value, onChange, ref, ...control }) => (
|
||||
<Input
|
||||
{...control}
|
||||
ref={ref}
|
||||
value={typeof value === "string" ? value : ""}
|
||||
onChange={onChange}
|
||||
placeholder="Agent.Invoke"
|
||||
/>
|
||||
)}
|
||||
</AgentFormField>
|
||||
|
||||
{!provisioningSource && (
|
||||
<AgentFormField
|
||||
name="identity_service_principal_id"
|
||||
label="Enterprise Application Object ID"
|
||||
rules={{
|
||||
required: mode !== "delegated" ? "Enter the service principal Object ID" : false,
|
||||
pattern: { value: IDENTITY_UUID_PATTERN, message: "Enter a valid service principal UUID" },
|
||||
}}
|
||||
description={
|
||||
<>
|
||||
Open{" "}
|
||||
<a
|
||||
className="underline"
|
||||
href="https://entra.microsoft.com/#view/Microsoft_AAD_IAM/StartboardApplicationsMenuBlade/~/AppAppsPreview"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
Entra Enterprise applications
|
||||
</a>
|
||||
, select this application, and copy its Object ID. The App registrations Object ID is a different
|
||||
value.
|
||||
</>
|
||||
}
|
||||
>
|
||||
{({ value, onChange, ref, ...control }) => (
|
||||
<Input {...control} ref={ref} value={typeof value === "string" ? value : ""} onChange={onChange} />
|
||||
)}
|
||||
</AgentFormField>
|
||||
)}
|
||||
{!provisioningSource && (
|
||||
<AgentFormField
|
||||
name="identity_required_roles"
|
||||
label="Required Application Roles"
|
||||
description="Comma-separated role values required on autonomous application tokens"
|
||||
>
|
||||
{({ value, onChange, ref, ...control }) => (
|
||||
<Input
|
||||
{...control}
|
||||
ref={ref}
|
||||
value={typeof value === "string" ? value : ""}
|
||||
onChange={onChange}
|
||||
placeholder="Agent.Invoke"
|
||||
/>
|
||||
)}
|
||||
</AgentFormField>
|
||||
)}
|
||||
{showScopes && (
|
||||
<>
|
||||
<AgentFormField
|
||||
name="identity_required_scopes"
|
||||
label="Required Delegated Scopes"
|
||||
label={provisioningSource ? "Required Token Scopes" : "Required Delegated Scopes"}
|
||||
defaultValue="user_impersonation"
|
||||
rules={{ required: "Enter a delegated scope" }}
|
||||
>
|
||||
|
|
@ -222,10 +238,12 @@ export const AgentIdentityFields = ({ accessToken }: { accessToken: string | nul
|
|||
/>
|
||||
)}
|
||||
</AgentFormField>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Users must first sign in through this gateway's Microsoft SSO. Subsequent delegated calls must
|
||||
satisfy both user and agent permissions.
|
||||
</p>
|
||||
{!provisioningSource && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Users must first sign in through this gateway's Microsoft SSO. Subsequent delegated calls must
|
||||
satisfy both user and agent permissions.
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<AgentFormField name="enabled" label="Execution" defaultValue={true}>
|
||||
|
|
|
|||
|
|
@ -76,7 +76,22 @@ describe("agent identity configuration", () => {
|
|||
};
|
||||
expect(() => buildIdentityParams(values)).toThrow("Enterprise application Object ID");
|
||||
});
|
||||
|
||||
it("preserves directory ownership while editing a native agent without an app-only principal", () => {
|
||||
const native = { ...identity, service_principal_id: null, provisioning_source_id: "source-one" };
|
||||
const values = parseIdentityForForm({
|
||||
identity: {
|
||||
...native,
|
||||
agent_id: "native-agent",
|
||||
active: true,
|
||||
revision: "rev",
|
||||
issuer: "https://issuer.example",
|
||||
},
|
||||
execution_mode: "autonomous",
|
||||
enabled: false,
|
||||
});
|
||||
expect(values.identity_provisioning_source_id).toBe("source-one");
|
||||
expect(buildIdentityParams(values, native)).toEqual({ identity: native });
|
||||
});
|
||||
it("only offers tenant-specific Microsoft issuers", () => {
|
||||
expect(entraTenantFromIssuer(`https://login.microsoftonline.com/${identity.tenant_id}/v2.0`)).toBe(
|
||||
identity.tenant_id,
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ const identityShape = {
|
|||
tenant_id: z.string().regex(IDENTITY_UUID_PATTERN),
|
||||
client_id: z.string().regex(IDENTITY_UUID_PATTERN),
|
||||
service_principal_id: z.string().regex(IDENTITY_UUID_PATTERN).nullable().default(null),
|
||||
provisioning_source_id: z.string().nullable().optional(),
|
||||
required_roles: stringGrants([]),
|
||||
required_scopes: stringGrants(["user_impersonation"]),
|
||||
};
|
||||
|
|
@ -37,6 +38,7 @@ const identityFormFields = (identity: EntraAgentIdentity | null): AgentFormValue
|
|||
identity_tenant_id: identity?.tenant_id ?? "",
|
||||
identity_client_id: identity?.client_id ?? "",
|
||||
identity_service_principal_id: identity?.service_principal_id ?? "",
|
||||
identity_provisioning_source_id: identity?.provisioning_source_id ?? "",
|
||||
identity_required_roles: identity?.required_roles?.join(", ") ?? "",
|
||||
identity_required_scopes: identity?.required_scopes?.join(", ") ?? "user_impersonation",
|
||||
});
|
||||
|
|
@ -67,7 +69,9 @@ export const buildIdentityParams = (
|
|||
if (values.identity_provider === undefined) return {};
|
||||
if (values.identity_provider !== "microsoft_entra")
|
||||
return readAgentIdentity(existingIdentity) ? { identity: null } : {};
|
||||
const provisioningSource = readAgentIdentity(existingIdentity)?.provisioning_source_id;
|
||||
const candidate: EntraAgentIdentity = {
|
||||
...(provisioningSource ? { provisioning_source_id: provisioningSource } : {}),
|
||||
provider: "microsoft_entra",
|
||||
tenant_id: typeof values.identity_tenant_id === "string" ? values.identity_tenant_id.trim().toLowerCase() : "",
|
||||
client_id: typeof values.identity_client_id === "string" ? values.identity_client_id.trim().toLowerCase() : "",
|
||||
|
|
@ -80,7 +84,7 @@ export const buildIdentityParams = (
|
|||
};
|
||||
const identity = readAgentIdentity(candidate);
|
||||
if (!identity) throw new Error("Enter valid Entra tenant, application client and service principal IDs");
|
||||
if (values.execution_mode !== "delegated" && !identity.service_principal_id)
|
||||
if (values.execution_mode !== "delegated" && !identity.service_principal_id && !identity.provisioning_source_id)
|
||||
throw new Error("Autonomous agents require the Enterprise application Object ID");
|
||||
return { identity };
|
||||
};
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import { toast } from "@/lib/toast";
|
|||
|
||||
vi.mock("./networking", () => ({
|
||||
keyCreateCall: vi.fn(),
|
||||
apiClient: { get: vi.fn().mockResolvedValue([]) },
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/toast", () => ({
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { SCIMAgentProvisioning } from "./SCIMAgentProvisioning";
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { z } from "zod/v4";
|
||||
import { keyCreateCall } from "./networking";
|
||||
|
|
@ -184,6 +185,7 @@ const SCIMConfig: React.FC<SCIMConfigProps> = ({ accessToken, userID, proxySetti
|
|||
)}
|
||||
</div>
|
||||
</div>
|
||||
<SCIMAgentProvisioning accessToken={accessToken} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,66 @@
|
|||
import { fireEvent, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { renderWithProviders, testQueryClient } from "../../tests/test-utils";
|
||||
import { SCIMAgentProvisioning } from "./SCIMAgentProvisioning";
|
||||
import { apiClient } from "./networking";
|
||||
import { toast } from "@/lib/toast";
|
||||
|
||||
vi.mock("./networking", () => ({ apiClient: { get: vi.fn(), post: vi.fn(), put: vi.fn() } }));
|
||||
vi.mock("@/lib/toast", () => ({ toast: { success: vi.fn(), fromError: vi.fn() } }));
|
||||
vi.mock("@/components/common_components/AccessGroupSelector", () => ({ default: () => null }));
|
||||
|
||||
const source = {
|
||||
source_id: "source-one",
|
||||
display_name: "Engineering",
|
||||
tenant_id: "11111111-1111-4111-8111-111111111111",
|
||||
enabled: true,
|
||||
group_mappings: [],
|
||||
};
|
||||
|
||||
describe("SCIM agent source configuration", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
testQueryClient.clear();
|
||||
vi.mocked(apiClient.get).mockResolvedValue([]);
|
||||
});
|
||||
|
||||
it("changes a newly created source through update and clears the submitted secret", async () => {
|
||||
vi.mocked(apiClient.post).mockResolvedValue(source);
|
||||
vi.mocked(apiClient.put).mockResolvedValue({ ...source, enabled: false });
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<SCIMAgentProvisioning accessToken="admin-token" />);
|
||||
fireEvent.change(screen.getByLabelText("Source name"), { target: { value: source.display_name } });
|
||||
fireEvent.change(screen.getByLabelText("Entra tenant ID"), { target: { value: source.tenant_id } });
|
||||
fireEvent.change(screen.getByLabelText(/Dedicated SCIM token/), { target: { value: "test-scim-token" } });
|
||||
await user.click(screen.getByRole("button", { name: "Save provisioning source" }));
|
||||
await waitFor(() => expect(toast.success).toHaveBeenCalledOnce());
|
||||
expect(screen.getByLabelText("Entra tenant ID")).toBeDisabled();
|
||||
expect(screen.queryByLabelText(/Dedicated SCIM token/)).not.toBeInTheDocument();
|
||||
await user.click(screen.getByLabelText("Enable this provisioning source"));
|
||||
await user.click(screen.getByRole("button", { name: "Save provisioning source" }));
|
||||
await waitFor(() =>
|
||||
expect(apiClient.put).toHaveBeenCalledWith("/scim/v2/sources/source-one", {
|
||||
accessToken: "admin-token",
|
||||
body: { display_name: source.display_name, tenant_id: source.tenant_id, enabled: false, group_mappings: [] },
|
||||
}),
|
||||
);
|
||||
expect(apiClient.post).toHaveBeenCalledOnce();
|
||||
await user.click(screen.getByRole("button", { name: "New source" }));
|
||||
expect(screen.getByLabelText(/Dedicated SCIM token/)).toHaveValue("");
|
||||
});
|
||||
|
||||
it("preserves an existing source when a save fails and shows the error", async () => {
|
||||
vi.mocked(apiClient.get).mockResolvedValue([source]);
|
||||
const failure = new Error("A mapped access group does not exist");
|
||||
vi.mocked(apiClient.put).mockRejectedValue(failure);
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<SCIMAgentProvisioning accessToken="admin-token" />);
|
||||
await user.click(await screen.findByRole("button", { name: "Engineering" }));
|
||||
await user.click(screen.getByRole("button", { name: "Save provisioning source" }));
|
||||
await waitFor(() => expect(toast.fromError).toHaveBeenCalledWith(failure));
|
||||
expect(toast.success).not.toHaveBeenCalled();
|
||||
expect(screen.getByLabelText("Entra tenant ID")).toBeDisabled();
|
||||
expect(screen.getByRole("button", { name: "Save provisioning source" })).toBeEnabled();
|
||||
});
|
||||
});
|
||||
178
ui/litellm-dashboard/src/components/SCIMAgentProvisioning.tsx
Normal file
178
ui/litellm-dashboard/src/components/SCIMAgentProvisioning.tsx
Normal file
|
|
@ -0,0 +1,178 @@
|
|||
import React, { useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import type { components } from "@/lib/http/schema";
|
||||
import { apiClient } from "@/components/networking";
|
||||
import AccessGroupSelector from "@/components/common_components/AccessGroupSelector";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { toast } from "@/lib/toast";
|
||||
|
||||
type Source = components["schemas"]["SCIMSourceResponse"];
|
||||
type Mapping = components["schemas"]["SCIMGroupMapping"];
|
||||
|
||||
export const SCIMAgentProvisioning = ({ accessToken }: { accessToken: string | null }) => {
|
||||
const [editing, setEditing] = useState<Source | null>(null);
|
||||
const [name, setName] = useState("");
|
||||
const [tenant, setTenant] = useState("");
|
||||
const [token, setToken] = useState("");
|
||||
const [enabled, setEnabled] = useState(true);
|
||||
const [mappings, setMappings] = useState<Mapping[]>([]);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const sources = useQuery({
|
||||
queryKey: ["scim-agent-sources"],
|
||||
queryFn: () => apiClient.get<Source[]>("/scim/v2/sources", { accessToken: accessToken ?? "" }),
|
||||
enabled: Boolean(accessToken),
|
||||
});
|
||||
|
||||
const edit = (source: Source | null) => {
|
||||
setEditing(source);
|
||||
setName(source?.display_name ?? "");
|
||||
setTenant(source?.tenant_id ?? "");
|
||||
setEnabled(source?.enabled ?? true);
|
||||
setMappings(source?.group_mappings ?? []);
|
||||
setToken("");
|
||||
};
|
||||
|
||||
const save = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
if (!accessToken) return;
|
||||
setSaving(true);
|
||||
const body = { display_name: name, tenant_id: tenant, enabled, group_mappings: mappings };
|
||||
try {
|
||||
const saved = editing
|
||||
? await apiClient.put<Source>(`/scim/v2/sources/${encodeURIComponent(editing.source_id)}`, {
|
||||
accessToken,
|
||||
body,
|
||||
})
|
||||
: await apiClient.post<Source>("/scim/v2/sources", {
|
||||
accessToken,
|
||||
body: { ...body, provisioning_token: token },
|
||||
});
|
||||
edit(saved);
|
||||
await sources.refetch();
|
||||
toast.success("Agent provisioning configuration saved");
|
||||
} catch (error) {
|
||||
toast.fromError(error);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section aria-label="Entra agent provisioning" className="mt-8 space-y-4 rounded-lg border p-4">
|
||||
<h3 className="text-lg font-medium">Entra agent provisioning</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Sync Entra agent-user accounts into Agents. New agents start disabled until you configure their permissions and
|
||||
enable them. Application service principals can be registered directly in Agents.
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
To inspect a synced agent, open{" "}
|
||||
<a className="underline" href="/ui/agents/">
|
||||
Agents
|
||||
</a>
|
||||
, select its Agent ID, and look for “Provisioned through Entra SCIM” on Overview. Agents registered directly do
|
||||
not have this label.
|
||||
</p>
|
||||
{sources.isError && <p role="alert">Could not load provisioning sources</p>}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{sources.data?.map((source) => (
|
||||
<Button key={source.source_id} type="button" variant="outline" onClick={() => edit(source)}>
|
||||
{source.display_name}
|
||||
</Button>
|
||||
))}
|
||||
<Button type="button" variant="outline" onClick={() => edit(null)}>
|
||||
New source
|
||||
</Button>
|
||||
</div>
|
||||
<form onSubmit={save} className="space-y-4">
|
||||
<label className="block text-sm">
|
||||
Source name
|
||||
<Input required value={name} onChange={(event) => setName(event.target.value)} />
|
||||
</label>
|
||||
<label className="block text-sm">
|
||||
Entra tenant ID
|
||||
<Input
|
||||
required
|
||||
disabled={Boolean(editing)}
|
||||
value={tenant}
|
||||
onChange={(event) => setTenant(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
{!editing && (
|
||||
<label className="block text-sm">
|
||||
Dedicated SCIM token
|
||||
<Input
|
||||
required
|
||||
type="password"
|
||||
autoComplete="off"
|
||||
value={token}
|
||||
onChange={(event) => setToken(event.target.value)}
|
||||
/>
|
||||
<span className="text-muted-foreground">Use a token created above, restricted to SCIM routes</span>
|
||||
</label>
|
||||
)}
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input type="checkbox" checked={enabled} onChange={(event) => setEnabled(event.target.checked)} />
|
||||
Enable this provisioning source
|
||||
</label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Map Entra group object IDs to existing access groups. Provisioned agents need a mapped group as well as their
|
||||
own resource permissions.
|
||||
</p>
|
||||
{mappings.map((mapping, index) => (
|
||||
<div key={index} className="space-y-2 rounded border p-3">
|
||||
<label className="block text-sm">
|
||||
Entra group object ID
|
||||
<Input
|
||||
required
|
||||
value={mapping.external_group_id}
|
||||
onChange={(event) =>
|
||||
setMappings((current) =>
|
||||
current.map((item, position) =>
|
||||
position === index ? { ...item, external_group_id: event.target.value } : item,
|
||||
),
|
||||
)
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<AccessGroupSelector
|
||||
value={mapping.access_group_ids}
|
||||
onChange={(ids) =>
|
||||
setMappings((current) =>
|
||||
current.map((item, position) => (position === index ? { ...item, access_group_ids: ids } : item)),
|
||||
)
|
||||
}
|
||||
showLabel
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() => setMappings((current) => current.filter((_, position) => position !== index))}
|
||||
>
|
||||
Remove mapping
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setMappings((current) => [...current, { external_group_id: "", access_group_ids: [] }])}
|
||||
>
|
||||
Add group mapping
|
||||
</Button>
|
||||
<Button type="submit" disabled={saving}>
|
||||
{saving ? "Saving" : "Save provisioning source"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
In Entra provisioning, map objectId to externalId and identityParentId to the LiteLLM agent-user extension. Use
|
||||
the SCIM URL above and sync only assigned users and groups.
|
||||
</p>
|
||||
<code className="block break-all text-xs">
|
||||
urn:ietf:params:scim:schemas:extension:litellmAgent:2.0:User:identityParentId
|
||||
</code>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
Loading…
Add table
Reference in a new issue