mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-11 03:38:38 +00:00
feat(agents): authorize Entra agent identities by Agent ID blueprint
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
parent
7be2983f11
commit
9f2fd054c0
16 changed files with 328 additions and 2 deletions
|
|
@ -0,0 +1,2 @@
|
|||
-- AlterTable
|
||||
ALTER TABLE "LiteLLM_AgentIdentity" ADD COLUMN IF NOT EXISTS "blueprint_id" TEXT;
|
||||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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",)
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
)
|
||||
|
|
|
|||
|
|
@ -48,6 +48,7 @@ export const AgentIdentityDetails = ({
|
|||
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">Agent ID blueprint: {identity.blueprint_id || "Not required"}</p>
|
||||
</>
|
||||
<p className="text-sm">
|
||||
Execution: {data ? executionLabel : "Loading"} · Mode: {data?.execution_mode ?? "Loading"}
|
||||
|
|
|
|||
|
|
@ -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
|
|||
<Input {...control} ref={ref} value={typeof value === "string" ? value : ""} onChange={onChange} />
|
||||
)}
|
||||
</AgentFormField>
|
||||
<AgentFormField
|
||||
name="identity_blueprint_id"
|
||||
label="Agent ID Blueprint"
|
||||
rules={{
|
||||
validate: (value: unknown) => 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 }) => (
|
||||
<Input {...control} ref={ref} value={typeof value === "string" ? value : ""} onChange={onChange} />
|
||||
)}
|
||||
</AgentFormField>
|
||||
|
||||
<AgentFormField
|
||||
name="identity_required_roles"
|
||||
|
|
|
|||
|
|
@ -150,6 +150,7 @@ describe("AddAgentForm submit payload", () => {
|
|||
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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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"]),
|
||||
};
|
||||
|
|
|
|||
7
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
7
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
|
|
@ -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;
|
||||
/**
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue