diff --git a/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/AgentIdentityDetails.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/AgentIdentityDetails.test.tsx index ce54ab78d9c..710c93452a0 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/AgentIdentityDetails.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/AgentIdentityDetails.test.tsx @@ -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(); 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(); + 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( , diff --git a/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/AgentIdentityDetails.tsx b/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/AgentIdentityDetails.tsx index 12465c6e861..b493ed2898f 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/AgentIdentityDetails.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/AgentIdentityDetails.tsx @@ -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 = ({

Tenant: {identity.tenant_id}

- <> -

- Application (Client) ID: {identity.client_id} -

-

Enterprise application Object ID: {identity.service_principal_id || "Not configured"}

- + {identity.provisioning_source_id ? ( + <> +

+ Entra Parent Identity ID: {identity.client_id} +

+

Provisioned through Entra SCIM

+

Directory status: {directoryStatus(data?.directory_active)}

+

Mapped access groups: {data?.directory_access_group_ids?.length ?? 0}

+ + ) : ( + <> +

+ Application (Client) ID: {identity.client_id} +

+

+ Enterprise application Object ID: {identity.service_principal_id || "Not configured"} +

+ + )}

Execution: {data ? executionLabel : "Loading"} · Mode: {data?.execution_mode ?? "Loading"}

diff --git a/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/AgentIdentityFields.tsx b/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/AgentIdentityFields.tsx index 8495c99d59f..5be551e934c 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/AgentIdentityFields.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/AgentIdentityFields.tsx @@ -22,8 +22,9 @@ const EXECUTION_OPTIONS = [ export const AgentIdentityFields = ({ accessToken }: { accessToken: string | null }) => { const provider = useWatch({ name: "identity_provider" }); + const provisioningSource = useWatch({ name: "identity_provisioning_source_id" }); const mode = useWatch({ name: "execution_mode" }); - const showScopes = mode !== "autonomous" && mode !== undefined; + const showScopes = Boolean(provisioningSource) || (mode !== "autonomous" && mode !== undefined); const [tenants, setTenants] = useState([]); const [error, setError] = useState(null); @@ -64,6 +65,7 @@ export const AgentIdentityFields = ({ accessToken }: { accessToken: string | nul {({ value, onChange, id }) => ( + )} - - Open{" "} - - Entra Enterprise applications - - , select this application, and copy its Object ID. The App registrations Object ID is a different - value. - - } - > - {({ value, onChange, ref, ...control }) => ( - - )} - - - - {({ value, onChange, ref, ...control }) => ( - - )} - - + {!provisioningSource && ( + + Open{" "} + + Entra Enterprise applications + + , select this application, and copy its Object ID. The App registrations Object ID is a different + value. + + } + > + {({ value, onChange, ref, ...control }) => ( + + )} + + )} + {!provisioningSource && ( + + {({ value, onChange, ref, ...control }) => ( + + )} + + )} {showScopes && ( <> @@ -222,10 +238,12 @@ export const AgentIdentityFields = ({ accessToken }: { accessToken: string | nul /> )} -

- Users must first sign in through this gateway's Microsoft SSO. Subsequent delegated calls must - satisfy both user and agent permissions. -

+ {!provisioningSource && ( +

+ Users must first sign in through this gateway's Microsoft SSO. Subsequent delegated calls must + satisfy both user and agent permissions. +

+ )} )} diff --git a/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/agent_identity.test.ts b/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/agent_identity.test.ts index f54d6364ddc..489de915312 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/agent_identity.test.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/agent_identity.test.ts @@ -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, diff --git a/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/agent_identity.ts b/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/agent_identity.ts index 24f25a54f65..e38ede56801 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/agent_identity.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/agent_identity.ts @@ -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 }; }; diff --git a/ui/litellm-dashboard/src/components/SCIM.test.tsx b/ui/litellm-dashboard/src/components/SCIM.test.tsx index b2cb034517d..c63d319abf6 100644 --- a/ui/litellm-dashboard/src/components/SCIM.test.tsx +++ b/ui/litellm-dashboard/src/components/SCIM.test.tsx @@ -9,6 +9,7 @@ import { toast } from "@/lib/toast"; vi.mock("./networking", () => ({ keyCreateCall: vi.fn(), + apiClient: { get: vi.fn().mockResolvedValue([]) }, })); vi.mock("@/lib/toast", () => ({ diff --git a/ui/litellm-dashboard/src/components/SCIM.tsx b/ui/litellm-dashboard/src/components/SCIM.tsx index 12b47aeee6a..d9a54f34167 100644 --- a/ui/litellm-dashboard/src/components/SCIM.tsx +++ b/ui/litellm-dashboard/src/components/SCIM.tsx @@ -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 = ({ accessToken, userID, proxySetti )} + diff --git a/ui/litellm-dashboard/src/components/SCIMAgentProvisioning.test.tsx b/ui/litellm-dashboard/src/components/SCIMAgentProvisioning.test.tsx new file mode 100644 index 00000000000..f1497f40b4f --- /dev/null +++ b/ui/litellm-dashboard/src/components/SCIMAgentProvisioning.test.tsx @@ -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(); + 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(); + 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(); + }); +}); diff --git a/ui/litellm-dashboard/src/components/SCIMAgentProvisioning.tsx b/ui/litellm-dashboard/src/components/SCIMAgentProvisioning.tsx new file mode 100644 index 00000000000..18f3df77e30 --- /dev/null +++ b/ui/litellm-dashboard/src/components/SCIMAgentProvisioning.tsx @@ -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(null); + const [name, setName] = useState(""); + const [tenant, setTenant] = useState(""); + const [token, setToken] = useState(""); + const [enabled, setEnabled] = useState(true); + const [mappings, setMappings] = useState([]); + const [saving, setSaving] = useState(false); + const sources = useQuery({ + queryKey: ["scim-agent-sources"], + queryFn: () => apiClient.get("/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(`/scim/v2/sources/${encodeURIComponent(editing.source_id)}`, { + accessToken, + body, + }) + : await apiClient.post("/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 ( +
+

Entra agent provisioning

+

+ 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. +

+

+ To inspect a synced agent, open{" "} + + Agents + + , select its Agent ID, and look for “Provisioned through Entra SCIM” on Overview. Agents registered directly do + not have this label. +

+ {sources.isError &&

Could not load provisioning sources

} +
+ {sources.data?.map((source) => ( + + ))} + +
+
+ + + {!editing && ( + + )} + +

+ Map Entra group object IDs to existing access groups. Provisioned agents need a mapped group as well as their + own resource permissions. +

+ {mappings.map((mapping, index) => ( +
+ + + setMappings((current) => + current.map((item, position) => (position === index ? { ...item, access_group_ids: ids } : item)), + ) + } + showLabel + /> + +
+ ))} +
+ + +
+
+

+ 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. +

+ + urn:ietf:params:scim:schemas:extension:litellmAgent:2.0:User:identityParentId + +
+ ); +};