From 9a1a85e83f546a8cadc2b83f886188b03cbb84ef Mon Sep 17 00:00:00 2001
From: Joshua Valluru <326636767+joshua-berri@users.noreply.github.com>
Date: Mon, 28 Sep 2026 16:23:55 -0700
Subject: [PATCH] feat(agents): scim dashboard
---
.../_components/AgentIdentityDetails.test.tsx | 16 +-
.../_components/AgentIdentityDetails.tsx | 30 ++-
.../_components/AgentIdentityFields.tsx | 122 +++++++-----
.../agents/_components/agent_identity.test.ts | 17 +-
.../agents/_components/agent_identity.ts | 6 +-
.../src/components/SCIM.test.tsx | 1 +
ui/litellm-dashboard/src/components/SCIM.tsx | 2 +
.../components/SCIMAgentProvisioning.test.tsx | 66 +++++++
.../src/components/SCIMAgentProvisioning.tsx | 178 ++++++++++++++++++
9 files changed, 376 insertions(+), 62 deletions(-)
create mode 100644 ui/litellm-dashboard/src/components/SCIMAgentProvisioning.test.tsx
create mode 100644 ui/litellm-dashboard/src/components/SCIMAgentProvisioning.tsx
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 }) => (
{({ value, onChange, id }) => (
-
+
@@ -116,7 +122,7 @@ export const AgentIdentityFields = ({ accessToken }: { accessToken: string | nul
)}
)}
+ {provisioningSource && (
+
+ Provisioned Entra agent-user. Identity fields are owned by your directory. Configure permissions and
+ enable this agent when ready.
+
+ )}
{({ 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) => (
+ edit(source)}>
+ {source.display_name}
+
+ ))}
+ edit(null)}>
+ New source
+
+
+
+
+ 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
+
+
+ );
+};