This commit is contained in:
joshua-berri 2026-10-01 01:59:48 +00:00 • committed by GitHub
commit 0143a68c79
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 376 additions and 62 deletions

View file

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

View file

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

View file

@ -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&apos;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&apos;s Microsoft SSO. Subsequent delegated calls must
satisfy both user and agent permissions.
</p>
)}
</>
)}
<AgentFormField name="enabled" label="Execution" defaultValue={true}>

View file

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

View file

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

View file

@ -9,6 +9,7 @@ import { toast } from "@/lib/toast";
vi.mock("./networking", () => ({
keyCreateCall: vi.fn(),
apiClient: { get: vi.fn().mockResolvedValue([]) },
}));
vi.mock("@/lib/toast", () => ({

View file

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

View file

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

View 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>
);
};