From 9f2fd054c011a6da2cf7c25207c0615a8a146167 Mon Sep 17 00:00:00 2001
From: yassin
Date: Fri, 2 Oct 2026 09:06:37 +0000
Subject: [PATCH] feat(agents): authorize Entra agent identities by Agent ID
blueprint
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
---
.../migration.sql | 2 +
.../litellm_proxy_extras/schema.prisma | 1 +
litellm/constants.py | 1 +
litellm/proxy/_lazy_openapi_snapshot.json | 23 +++
.../proxy/agent_endpoints/managed_identity.py | 16 ++
litellm/proxy/schema.prisma | 1 +
litellm/types/proxy/agent_identity.py | 7 +-
schema.prisma | 1 +
.../agent_endpoints/test_identity_store.py | 18 ++-
.../agent_endpoints/test_managed_identity.py | 149 ++++++++++++++++++
.../_components/AgentIdentityDetails.tsx | 1 +
.../_components/AgentIdentityFields.tsx | 15 ++
.../add_agent_form.integration.test.tsx | 50 ++++++
.../agents/_components/agent_identity.test.ts | 32 ++++
.../agents/_components/agent_identity.ts | 6 +
ui/litellm-dashboard/src/lib/http/schema.d.ts | 7 +
16 files changed, 328 insertions(+), 2 deletions(-)
create mode 100644 litellm-proxy-extras/litellm_proxy_extras/migrations/20261002000000_agent_identity_blueprint/migration.sql
diff --git a/litellm-proxy-extras/litellm_proxy_extras/migrations/20261002000000_agent_identity_blueprint/migration.sql b/litellm-proxy-extras/litellm_proxy_extras/migrations/20261002000000_agent_identity_blueprint/migration.sql
new file mode 100644
index 00000000000..a89dc8507ba
--- /dev/null
+++ b/litellm-proxy-extras/litellm_proxy_extras/migrations/20261002000000_agent_identity_blueprint/migration.sql
@@ -0,0 +1,2 @@
+-- AlterTable
+ALTER TABLE "LiteLLM_AgentIdentity" ADD COLUMN IF NOT EXISTS "blueprint_id" TEXT;
diff --git a/litellm-proxy-extras/litellm_proxy_extras/schema.prisma b/litellm-proxy-extras/litellm_proxy_extras/schema.prisma
index 6f285e9dc39..2e732253118 100644
--- a/litellm-proxy-extras/litellm_proxy_extras/schema.prisma
+++ b/litellm-proxy-extras/litellm_proxy_extras/schema.prisma
@@ -102,6 +102,7 @@ model LiteLLM_AgentIdentity {
tenant_id String
client_id String
service_principal_id String?
+ blueprint_id String?
required_roles String[] @default([])
required_scopes String[] @default(["user_impersonation"])
revision String @default(uuid())
diff --git a/litellm/constants.py b/litellm/constants.py
index af4d1268c03..4bddc5af743 100644
--- a/litellm/constants.py
+++ b/litellm/constants.py
@@ -577,6 +577,7 @@ request_timeout_explicitly_set: bool = "REQUEST_TIMEOUT" in os.environ
DEFAULT_A2A_AGENT_TIMEOUT: Final[float] = float(os.getenv("DEFAULT_A2A_AGENT_TIMEOUT", 6000)) # 10 minutes
AGENT_KILL_SWITCH_TIMEOUT_SECONDS: Final = 10.0
AGENT_KILL_SWITCH_RESPONSE_BODY_MAX_CHARS: Final = 2000
+ENTRA_AGENT_IDENTITY_FACET: Final = "11"
# Patterns that indicate a localhost/internal URL in A2A agent cards that should be
# replaced with the original base_url. This is a common misconfiguration where
# developers deploy agents with development URLs in their agent cards.
diff --git a/litellm/proxy/_lazy_openapi_snapshot.json b/litellm/proxy/_lazy_openapi_snapshot.json
index 5de6e91a0f2..3c0aac346cd 100644
--- a/litellm/proxy/_lazy_openapi_snapshot.json
+++ b/litellm/proxy/_lazy_openapi_snapshot.json
@@ -2554,6 +2554,17 @@
"title": "Agent Id",
"type": "string"
},
+ "blueprint_id": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Blueprint Id"
+ },
"client_id": {
"title": "Client Id",
"type": "string"
@@ -3648,6 +3659,18 @@
"EntraIdentityConfig": {
"additionalProperties": false,
"properties": {
+ "blueprint_id": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "description": "Entra Agent ID blueprint application ID. When set, only tokens issued to an agent identity created from this blueprint are accepted",
+ "title": "Blueprint Id"
+ },
"client_id": {
"title": "Client Id",
"type": "string"
diff --git a/litellm/proxy/agent_endpoints/managed_identity.py b/litellm/proxy/agent_endpoints/managed_identity.py
index abab21901ee..7b3a8aceb6a 100644
--- a/litellm/proxy/agent_endpoints/managed_identity.py
+++ b/litellm/proxy/agent_endpoints/managed_identity.py
@@ -7,6 +7,7 @@ from fastapi import HTTPException
from pydantic import TypeAdapter, ValidationError
from typing_extensions import ReadOnly
+from litellm.constants import ENTRA_AGENT_IDENTITY_FACET
from litellm.types.agents import AgentResponse
from litellm.types.proxy.agent_identity import (
AgentExecutionMode,
@@ -25,6 +26,7 @@ class IdentityFields(TypedDict, total=False):
client_id: ReadOnly[str]
issuer: ReadOnly[str]
service_principal_id: ReadOnly[str | None]
+ blueprint_id: ReadOnly[str | None]
required_roles: ReadOnly[tuple[str, ...]]
required_scopes: ReadOnly[tuple[str, ...]]
active: ReadOnly[bool]
@@ -143,6 +145,7 @@ def _identity_write(identity: EntraIdentityConfig | None, existing: AgentRespons
"tenant_id": identity.tenant_id,
"client_id": identity.client_id,
"service_principal_id": identity.service_principal_id,
+ "blueprint_id": identity.blueprint_id,
"required_roles": identity.required_roles,
"required_scopes": identity.required_scopes,
"issuer": identity.issuer,
@@ -176,6 +179,8 @@ def classify_agent_subject(
binding.client_id,
):
return AgentIdentityFailure(message="Token does not match the registered Entra application")
+ if binding.blueprint_id is not None and not _issued_by_blueprint(claims, binding.blueprint_id):
+ return AgentIdentityFailure(message="Token was not issued to an agent identity of the configured blueprint")
oid: Final = claims.get("oid")
if not isinstance(oid, str) or not oid:
return AgentIdentityFailure(message="Entra token must identify its object subject")
@@ -200,3 +205,14 @@ def classify_agent_subject(
if not frozenset(binding.required_roles).issubset(roles):
return AgentIdentityFailure(message="Token lacks the required application roles")
return AgentSubject(kind="application", oid=oid, mode="autonomous")
+
+
+def _issued_by_blueprint(claims: Mapping[str, object], blueprint_id: str) -> bool:
+ parent: Final = claims.get("xms_par_app_azp")
+ actor: Final = claims.get("xms_act_fct")
+ return (
+ isinstance(parent, str)
+ and parent.lower() == blueprint_id
+ and isinstance(actor, str)
+ and ENTRA_AGENT_IDENTITY_FACET in actor.split()
+ )
diff --git a/litellm/proxy/schema.prisma b/litellm/proxy/schema.prisma
index 6f285e9dc39..2e732253118 100644
--- a/litellm/proxy/schema.prisma
+++ b/litellm/proxy/schema.prisma
@@ -102,6 +102,7 @@ model LiteLLM_AgentIdentity {
tenant_id String
client_id String
service_principal_id String?
+ blueprint_id String?
required_roles String[] @default([])
required_scopes String[] @default(["user_impersonation"])
revision String @default(uuid())
diff --git a/litellm/types/proxy/agent_identity.py b/litellm/types/proxy/agent_identity.py
index a7fe0be37e1..4ee1736d9fe 100644
--- a/litellm/types/proxy/agent_identity.py
+++ b/litellm/types/proxy/agent_identity.py
@@ -14,13 +14,17 @@ class EntraIdentityConfig(BaseModel):
tenant_id: str
client_id: str
service_principal_id: str | None = None
+ blueprint_id: str | None = Field(
+ default=None,
+ description="Entra Agent ID blueprint application ID. When set, only tokens issued to an agent identity created from this blueprint are accepted",
+ )
required_roles: tuple[str, ...] = ()
required_scopes: tuple[str, ...] = Field(
default=("user_impersonation",),
description="Required delegated scopes. An empty list accepts any nonempty scope granted for this gateway.",
)
- @field_validator("tenant_id", "client_id", "service_principal_id")
+ @field_validator("tenant_id", "client_id", "service_principal_id", "blueprint_id")
@classmethod
def normalize_identifier(cls, value: str | None) -> str | None:
return str(UUID(value)) if value is not None else None
@@ -39,6 +43,7 @@ class AgentIdentityBinding(BaseModel):
tenant_id: str
client_id: str
service_principal_id: str | None = None
+ blueprint_id: str | None = None
issuer: str
required_roles: tuple[str, ...] = ()
required_scopes: tuple[str, ...] = ("user_impersonation",)
diff --git a/schema.prisma b/schema.prisma
index 6f285e9dc39..2e732253118 100644
--- a/schema.prisma
+++ b/schema.prisma
@@ -102,6 +102,7 @@ model LiteLLM_AgentIdentity {
tenant_id String
client_id String
service_principal_id String?
+ blueprint_id String?
required_roles String[] @default([])
required_scopes String[] @default(["user_impersonation"])
revision String @default(uuid())
diff --git a/tests/unit/proxy/agent_endpoints/test_identity_store.py b/tests/unit/proxy/agent_endpoints/test_identity_store.py
index 005f0b4c074..215c40db22a 100644
--- a/tests/unit/proxy/agent_endpoints/test_identity_store.py
+++ b/tests/unit/proxy/agent_endpoints/test_identity_store.py
@@ -74,7 +74,9 @@ def setup_store(
)
)
return (
- AgentIdentityStore(AgentsRepository(db), AgentIdentityRepository(db), VerifiedSubjectRepository(db), cache=cache),
+ AgentIdentityStore(
+ AgentsRepository(db), AgentIdentityRepository(db), VerifiedSubjectRepository(db), cache=cache
+ ),
agents,
identities,
humans,
@@ -448,3 +450,17 @@ async def test_application_and_unregistered_clients_do_not_depend_on_human_subje
else:
assert result is None
humans.find_unique.assert_not_awaited()
+
+
+@pytest.mark.asyncio
+async def test_stored_blueprint_binding_is_enforced_on_every_request() -> None:
+ blueprint: Final = "55555555-5555-4555-8555-555555555555"
+ bound: Final = BINDING.model_copy(update={"blueprint_id": blueprint})
+ store, _, _, _ = setup_store(agent=stored_agent(identity=bound))
+ matching: Final = {**CLAIMS, "xms_par_app_azp": blueprint, "xms_act_fct": "3 9 11"}
+ assert isinstance(await store.resolve_verified_claims(matching), ManagedAgentContext)
+ foreign: Final = await store.resolve_verified_claims(
+ {**CLAIMS, "xms_par_app_azp": "66666666-6666-4666-8666-666666666666", "xms_act_fct": "3 9 11"}
+ )
+ assert isinstance(foreign, AgentIdentityFailure)
+ assert foreign.code == "identity_denied"
diff --git a/tests/unit/proxy/agent_endpoints/test_managed_identity.py b/tests/unit/proxy/agent_endpoints/test_managed_identity.py
index 17f3cdb52f5..78805dcca3f 100644
--- a/tests/unit/proxy/agent_endpoints/test_managed_identity.py
+++ b/tests/unit/proxy/agent_endpoints/test_managed_identity.py
@@ -1,6 +1,8 @@
+from datetime import datetime, timezone
from typing import Final
import pytest
+from pydantic import ValidationError
from litellm.proxy.agent_endpoints.managed_identity import classify_agent_subject, managed_write_fields
from litellm.types.agents import AgentResponse
@@ -9,6 +11,7 @@ from litellm.types.proxy.agent_identity import (
AgentIdentityBinding,
AgentIdentityFailure,
AgentSubject,
+ EntraIdentityConfig,
)
TENANT: Final = "11111111-1111-4111-8111-111111111111"
@@ -255,3 +258,149 @@ def test_empty_requirements_do_not_make_a_scope_less_human_token_valid(scope: ob
binding: Final = BINDING.model_copy(update={"required_scopes": ()})
result: Final = classify_agent_subject(binding, claims(oid=HUMAN, scp=scope), "both")
assert isinstance(result, AgentIdentityFailure)
+
+
+BLUEPRINT: Final = "55555555-5555-4555-8555-555555555555"
+BLUEPRINT_BINDING: Final = BINDING.model_copy(update={"blueprint_id": BLUEPRINT})
+
+
+def blueprint_claims(**overrides: object) -> dict[str, object]:
+ return claims(**{"xms_par_app_azp": BLUEPRINT, "xms_act_fct": "3 9 11", **overrides})
+
+
+def test_blueprint_binding_accepts_matching_agent_identity_token() -> None:
+ result: Final = classify_agent_subject(BLUEPRINT_BINDING, blueprint_claims(), "autonomous")
+ assert result == AgentSubject(kind="application", oid=PRINCIPAL, mode="autonomous")
+
+
+def test_blueprint_binding_accepts_uppercase_blueprint_claim() -> None:
+ result: Final = classify_agent_subject(
+ BLUEPRINT_BINDING, blueprint_claims(xms_par_app_azp=BLUEPRINT.upper()), "autonomous"
+ )
+ assert result == AgentSubject(kind="application", oid=PRINCIPAL, mode="autonomous")
+
+
+def test_blueprint_binding_accepts_matching_delegated_token() -> None:
+ result: Final = classify_agent_subject(
+ BLUEPRINT_BINDING,
+ blueprint_claims(oid=HUMAN, scp="user_impersonation"),
+ "delegated",
+ )
+ assert result == AgentSubject(kind="delegated_subject", oid=HUMAN, mode="delegated")
+
+
+@pytest.mark.parametrize(
+ "overrides",
+ [
+ {"xms_par_app_azp": None},
+ {"xms_par_app_azp": "66666666-6666-4666-8666-666666666666"},
+ {"xms_par_app_azp": 11},
+ {"xms_act_fct": None},
+ {"xms_act_fct": "3 9"},
+ {"xms_act_fct": "111"},
+ {"xms_act_fct": [11]},
+ ],
+)
+@pytest.mark.parametrize("shape", ["autonomous", "delegated"])
+def test_blueprint_binding_rejects_tokens_outside_the_pinned_blueprint(
+ overrides: dict[str, object], shape: str
+) -> None:
+ extra: Final = {} if shape == "autonomous" else {"oid": HUMAN, "scp": "user_impersonation"}
+ blue_claims: Final = blueprint_claims(**extra)
+ for key, value in overrides.items():
+ if value is None:
+ blue_claims.pop(key)
+ else:
+ blue_claims[key] = value
+ result: Final = classify_agent_subject(BLUEPRINT_BINDING, blue_claims, "both")
+ assert isinstance(result, AgentIdentityFailure)
+ assert result.message == "Token was not issued to an agent identity of the configured blueprint"
+
+
+def test_unbound_blueprint_binding_accepts_foreign_parent_claim() -> None:
+ result: Final = classify_agent_subject(
+ BINDING,
+ claims(xms_par_app_azp="66666666-6666-4666-8666-666666666666"),
+ "autonomous",
+ )
+ assert result == AgentSubject(kind="application", oid=PRINCIPAL, mode="autonomous")
+
+
+def test_new_binding_carries_normalized_blueprint_id() -> None:
+ created: Final = managed_write_fields(
+ {
+ "identity": {
+ "provider": "microsoft_entra",
+ "tenant_id": TENANT,
+ "client_id": CLIENT,
+ "service_principal_id": PRINCIPAL,
+ "blueprint_id": BLUEPRINT.upper(),
+ }
+ },
+ None,
+ "admin",
+ )
+ assert not isinstance(created, AgentIdentityFailure)
+ assert created.get("identity", {}).get("create", {}).get("blueprint_id") == BLUEPRINT
+ replacement: Final = managed_write_fields(
+ {
+ "identity": {
+ "provider": "microsoft_entra",
+ "tenant_id": TENANT,
+ "client_id": CLIENT,
+ "service_principal_id": PRINCIPAL,
+ "blueprint_id": BLUEPRINT.upper(),
+ }
+ },
+ managed_agent(),
+ "admin",
+ )
+ assert not isinstance(replacement, AgentIdentityFailure)
+ assert replacement.get("identity", {}).get("upsert", {}).get("update", {}).get("blueprint_id") == BLUEPRINT
+
+
+def test_blueprint_only_change_rebinds_with_new_revision_and_cleared_evidence() -> None:
+ agent: Final = managed_agent().model_copy(
+ update={
+ "identity": BLUEPRINT_BINDING.model_copy(
+ update={"last_authenticated_at": datetime(2026, 9, 30, tzinfo=timezone.utc)}
+ )
+ }
+ )
+ result: Final = managed_write_fields(
+ {
+ "identity": {
+ "provider": "microsoft_entra",
+ "tenant_id": TENANT,
+ "client_id": CLIENT,
+ "service_principal_id": PRINCIPAL,
+ "required_roles": ["Agent.Invoke"],
+ "required_scopes": ["user_impersonation"],
+ "blueprint_id": "66666666-6666-4666-8666-666666666666",
+ }
+ },
+ agent,
+ "admin",
+ )
+ assert not isinstance(result, AgentIdentityFailure)
+ update: Final = result.get("identity", {}).get("upsert", {}).get("update", {})
+ assert update
+ assert update.get("revision") != BLUEPRINT_BINDING.revision
+ assert update.get("last_authenticated_at") is None
+
+
+def test_entra_config_normalizes_and_validates_blueprint_id() -> None:
+ config: Final = EntraIdentityConfig(
+ provider="microsoft_entra",
+ tenant_id=TENANT,
+ client_id=CLIENT,
+ blueprint_id=BLUEPRINT.upper(),
+ )
+ assert config.blueprint_id == BLUEPRINT
+ with pytest.raises(ValidationError):
+ EntraIdentityConfig(
+ provider="microsoft_entra",
+ tenant_id=TENANT,
+ client_id=CLIENT,
+ blueprint_id="not-a-uuid",
+ )
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..544ea8d49f6 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/AgentIdentityDetails.tsx
+++ b/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/AgentIdentityDetails.tsx
@@ -48,6 +48,7 @@ export const AgentIdentityDetails = ({
Application (Client) ID: {identity.client_id}
Enterprise application Object ID: {identity.service_principal_id || "Not configured"}
+ Agent ID blueprint: {identity.blueprint_id || "Not required"}
>
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 50c60776ff3..d92681ca9a0 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/AgentIdentityFields.tsx
+++ b/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/AgentIdentityFields.tsx
@@ -15,6 +15,9 @@ const EXECUTION_MODE_OPTIONS = [
{ value: "delegated", label: "On behalf of a user" },
{ value: "both", label: "Both" },
];
+const isBlankOrUuid = (value: unknown): boolean =>
+ typeof value !== "string" || !value.trim() || IDENTITY_UUID_PATTERN.test(value.trim());
+
const EXECUTION_OPTIONS = [
{ value: "enabled", label: "Enabled" },
{ value: "disabled", label: "Disabled" },
@@ -188,6 +191,18 @@ export const AgentIdentityFields = ({ accessToken }: { accessToken: string | nul
)}
+ isBlankOrUuid(value) || "Enter a valid blueprint application UUID",
+ }}
+ description="Optional. The Entra Agent ID blueprint application ID that created this agent identity. When set, tokens from agent identities of other blueprints are rejected"
+ >
+ {({ value, onChange, ref, ...control }) => (
+
+ )}
+
{
tenant_id: tenant,
client_id: clientId,
service_principal_id: "33333333-3333-4333-8333-333333333333",
+ blueprint_id: null,
required_roles: [],
required_scopes: ["user_impersonation"],
};
@@ -163,6 +164,55 @@ describe("AddAgentForm submit payload", () => {
).toBeInTheDocument();
});
+ it("sends the Agent ID Blueprint field as identity.blueprint_id", async () => {
+ const user = userEvent.setup({ pointerEventsCheck: PointerEventsCheckLevel.Never });
+ const tenant = "11111111-1111-4111-8111-111111111111";
+ const blueprint = "55555555-5555-4555-8555-555555555555";
+ vi.mocked(networking.apiClient.get).mockResolvedValue([`https://login.microsoftonline.com/${tenant}/v2.0`]);
+ renderForm();
+ fireEvent.change(await screen.findByLabelText("Agent Name"), { target: { value: "Bound agent" } });
+ fireEvent.change(screen.getByLabelText("URL"), { target: { value: "https://runtime.example/a2a" } });
+ fireEvent.change(screen.getByLabelText("Display Name"), { target: { value: "Bound agent" } });
+ fireEvent.change(screen.getByPlaceholderText("Describe what this agent does..."), {
+ target: { value: "Test agent" },
+ });
+ await user.click(screen.getByLabelText("Identity Provider"));
+ await user.click(await screen.findByRole("option", { name: "Microsoft Entra ID" }));
+ await user.click(screen.getByLabelText("Trusted Entra Tenant"));
+ await user.click(await screen.findByRole("option", { name: tenant }));
+ fireEvent.change(screen.getByLabelText("Application (Client) ID"), {
+ target: { value: "22222222-2222-4222-8222-222222222222" },
+ });
+ fireEvent.change(screen.getByLabelText("Enterprise Application Object ID"), {
+ target: { value: "33333333-3333-4333-8333-333333333333" },
+ });
+ fireEvent.change(screen.getByLabelText("Agent ID Blueprint"), { target: { value: blueprint } });
+ await user.click(screen.getByRole("button", { name: /^Next/ }));
+ await user.click(screen.getByRole("button", { name: /^Next/ }));
+ await user.click(screen.getByRole("button", { name: /^Next/ }));
+ await user.click(screen.getByRole("button", { name: "Use Entra JWT authentication" }));
+ await user.click(screen.getByRole("button", { name: /Create Agent/ }));
+ await waitFor(() => expect(networking.createAgentCall).toHaveBeenCalledTimes(1));
+ const payload = createdPayload();
+ expect(payload.identity).toMatchObject({ blueprint_id: blueprint });
+ });
+
+ it("trims surrounding spaces before validating the Agent ID Blueprint UUID", async () => {
+ const user = userEvent.setup({ pointerEventsCheck: PointerEventsCheckLevel.Never });
+ const tenant = "11111111-1111-4111-8111-111111111111";
+ vi.mocked(networking.apiClient.get).mockResolvedValue([`https://login.microsoftonline.com/${tenant}/v2.0`]);
+ renderForm();
+ await user.click(await screen.findByLabelText("Identity Provider"));
+ await user.click(await screen.findByRole("option", { name: "Microsoft Entra ID" }));
+ const blueprint = screen.getByLabelText("Agent ID Blueprint");
+ fireEvent.change(blueprint, { target: { value: "not-a-uuid" } });
+ await user.click(screen.getByRole("button", { name: /^Next/ }));
+ expect(await screen.findByText("Enter a valid blueprint application UUID")).toBeInTheDocument();
+ fireEvent.change(blueprint, { target: { value: " 55555555-5555-4555-8555-555555555555 " } });
+ await user.click(screen.getByRole("button", { name: /^Next/ }));
+ await waitFor(() => expect(screen.queryByText("Enter a valid blueprint application UUID")).not.toBeInTheDocument());
+ });
+
it("sends every a2a field the user filled across all collapsible panels", async () => {
const user = userEvent.setup({ pointerEventsCheck: PointerEventsCheckLevel.Never });
renderForm();
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 0639e7a6dd4..b0a8a122d6d 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
@@ -12,6 +12,7 @@ const identity = {
tenant_id: "11111111-1111-4111-8111-111111111111",
client_id: "22222222-2222-4222-8222-222222222222",
service_principal_id: "33333333-3333-4333-8333-333333333333",
+ blueprint_id: null,
required_roles: ["Agent.Invoke"],
required_scopes: ["user_impersonation"],
} satisfies import("./agent_identity").EntraAgentIdentity;
@@ -81,3 +82,34 @@ describe("agent identity configuration", () => {
expect(entraTenantFromIssuer("https://login.microsoftonline.com/common/v2.0")).toBeNull();
});
});
+
+const blueprint = "55555555-5555-4555-8555-555555555555";
+
+describe("agent identity blueprint", () => {
+ it("normalizes a pasted blueprint id and clears an empty one", () => {
+ const values = {
+ identity_provider: "microsoft_entra",
+ identity_tenant_id: identity.tenant_id,
+ identity_client_id: identity.client_id,
+ identity_service_principal_id: identity.service_principal_id,
+ identity_blueprint_id: ` ${blueprint.toUpperCase()} `,
+ execution_mode: "autonomous",
+ };
+ expect(buildIdentityParams(values).identity?.blueprint_id).toBe(blueprint);
+ expect(buildIdentityParams({ ...values, identity_blueprint_id: " " }).identity?.blueprint_id).toBeNull();
+ });
+ it("round trips the blueprint through the form fields", () => {
+ const values = parseIdentityForForm({
+ identity: {
+ ...identity,
+ blueprint_id: blueprint,
+ agent_id: "stable",
+ active: true,
+ revision: "rev",
+ issuer: "https://issuer.example",
+ },
+ });
+ expect(values.identity_blueprint_id).toBe(blueprint);
+ expect(buildIdentityParams(values).identity?.blueprint_id).toBe(blueprint);
+ });
+});
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 23045adcf20..b427582d2c0 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),
+ blueprint_id: z.string().regex(IDENTITY_UUID_PATTERN).nullable().default(null),
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_blueprint_id: identity?.blueprint_id ?? "",
identity_required_roles: identity?.required_roles?.join(", ") ?? "",
identity_required_scopes: identity?.required_scopes?.join(", ") ?? "user_impersonation",
});
@@ -73,6 +75,10 @@ export const buildIdentityParams = (
typeof values.identity_service_principal_id === "string" && values.identity_service_principal_id.trim()
? values.identity_service_principal_id.trim().toLowerCase()
: null,
+ blueprint_id:
+ typeof values.identity_blueprint_id === "string" && values.identity_blueprint_id.trim()
+ ? values.identity_blueprint_id.trim().toLowerCase()
+ : null,
required_roles: splitGrants(values.identity_required_roles, []),
required_scopes: splitGrants(values.identity_required_scopes, ["user_impersonation"]),
};
diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts
index b72f2503e5d..b5ac4a10f27 100644
--- a/ui/litellm-dashboard/src/lib/http/schema.d.ts
+++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts
@@ -25495,6 +25495,8 @@ export interface components {
active: boolean;
/** Agent Id */
agent_id: string;
+ /** Blueprint Id */
+ blueprint_id?: string | null;
/** Client Id */
client_id: string;
/** Issuer */
@@ -31520,6 +31522,11 @@ export interface components {
};
/** EntraIdentityConfig */
EntraIdentityConfig: {
+ /**
+ * Blueprint Id
+ * @description Entra Agent ID blueprint application ID. When set, only tokens issued to an agent identity created from this blueprint are accepted
+ */
+ blueprint_id?: string | null;
/** Client Id */
client_id: string;
/**