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; /**