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 cf76b764350..42fa7da2431 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 d58fc8a6318..4eb94234d63 100644 --- a/litellm/constants.py +++ b/litellm/constants.py @@ -578,6 +578,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 a927d4b415f..b9cb531d43a 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 cf76b764350..42fa7da2431 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 cf76b764350..42fa7da2431 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
)}
+