From f3ffeb38ae90f651926ca186c8f94cb73e36adf62fd4ded34ba03cb945c0d3f7 Mon Sep 17 00:00:00 2001 From: Tyler King Date: Sat, 4 Apr 2026 16:52:45 -0400 Subject: [PATCH] feat: AgentRegistrar abstraction + Entra Agent ID driver MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extracts agent identity registration behind AgentRegistrar protocol. Three implementations: KeycloakRegistrar — Keycloak Admin REST API (existing, refactored) EntraRegistrar — Microsoft Entra Agent ID platform (NEW) StubRegistrar — dev mode without real IdP Driver selection via AGENT_REGISTRAR env var: auto — prefers Entra if configured, Keycloak fallback, stub default keycloak — explicit Keycloak entra — explicit Entra Agent ID Entra integration: Registers agent as Entra app + service principal via Graph API Tags with delegation metadata (agent_type, delegator, governed:true) Client secret TTL matches delegation expiry Deletes application on revocation (cascades to SP) Uses msal for token acquisition Future: native Agent ID Blueprint API when GA Files: registrar.py — AgentRegistrar protocol + AgentCredentials dataclass registrar_keycloak.py — refactored from keycloak.py registrar_entra.py — NEW Entra Graph API driver registrar_stub.py — dev mode stub registrar_factory.py — driver selection factory delegation.py — updated to use registrar abstraction settings.py — added Entra config + agent_registrar field All 7 smoke tests pass with stub registrar. Implements GCAP-SPEC-LLM-PRINCIPAL-BROKER-0001 §4.1-§4.3. Co-Authored-By: Claude Opus 4.6 (1M context) --- .env.example | 9 ++ llm_broker/delegation.py | 40 +++-- llm_broker/registrar.py | 36 +++++ llm_broker/registrar_entra.py | 153 ++++++++++++++++++ llm_broker/registrar_factory.py | 64 ++++++++ .../{keycloak.py => registrar_keycloak.py} | 64 ++++---- llm_broker/registrar_stub.py | 35 ++++ llm_broker/settings.py | 9 ++ pyproject.toml | 1 + 9 files changed, 355 insertions(+), 56 deletions(-) create mode 100644 llm_broker/registrar.py create mode 100644 llm_broker/registrar_entra.py create mode 100644 llm_broker/registrar_factory.py rename llm_broker/{keycloak.py => registrar_keycloak.py} (70%) create mode 100644 llm_broker/registrar_stub.py diff --git a/.env.example b/.env.example index 764cb12..a190ca4 100644 --- a/.env.example +++ b/.env.example @@ -16,6 +16,15 @@ KEYCLOAK_ADMIN_CLIENT_SECRET= # Chronicle (optional — events posted as Forgejo push webhooks) CHRONICLE_WEBHOOK_URL= +# Agent registrar driver: auto | keycloak | entra +AGENT_REGISTRAR=auto + +# Entra Agent ID (when AGENT_REGISTRAR=entra or auto) +ENTRA_TENANT_ID= +ENTRA_CLIENT_ID= +ENTRA_CLIENT_SECRET= +ENTRA_AGENT_BLUEPRINT_ID= + # Delegation Defaults DEFAULT_DELEGATION_TTL_MINUTES=60 DEFAULT_MAX_COMMANDS=500 diff --git a/llm_broker/delegation.py b/llm_broker/delegation.py index bc30118..cdd8d20 100644 --- a/llm_broker/delegation.py +++ b/llm_broker/delegation.py @@ -7,13 +7,13 @@ from datetime import datetime, timedelta from llm_broker import chronicle, db from llm_broker.db import DelegationDB from llm_broker.gsap import GSAPClient -from llm_broker.keycloak import KeycloakAdmin from llm_broker.models import ( AgentPrincipal, DelegationRequest, DelegationResponse, DelegationScope, ) +from llm_broker.registrar_factory import create_registrar from llm_broker.settings import Settings logger = logging.getLogger(__name__) @@ -22,12 +22,7 @@ logger = logging.getLogger(__name__) class DelegationManager: def __init__(self, config: Settings): self.config = config - self.keycloak = KeycloakAdmin( - config.keycloak_url, - config.keycloak_realm, - config.keycloak_admin_client_id, - config.keycloak_admin_client_secret, - ) + self.registrar = create_registrar(config) self.gsap = GSAPClient(config.gsap_broker_url, config.gsap_bearer_token) async def create_delegation( @@ -39,17 +34,17 @@ class DelegationManager: expires_at = now + timedelta(minutes=scope.max_ttl_minutes) agent_did = f"did:web:guildhouse.dev/agent/{request.agent_type}-{delegation_id}" - agent_client_id = f"agent-{request.agent_type}-{delegation_id}" delegator_short = delegator_did.rsplit("/", 1)[-1] agent_display = f"{request.agent_type} (delegated by {delegator_short})" - # 1. Register agent in Keycloak - kc_result = await self.keycloak.register_agent_client( - client_id=agent_client_id, - display_name=agent_display, - delegator_did=delegator_did, + # 1. Register agent identity via the configured registrar + credentials = await self.registrar.register_agent( delegation_id=delegation_id, agent_type=request.agent_type, + delegator_id=delegator_did, + display_name=agent_display, + expires_at=expires_at.isoformat(), + metadata={"model": request.agent_model}, ) # 2. Request delegated AC from GSAP broker @@ -63,7 +58,7 @@ class DelegationManager: ttl_minutes=scope.max_ttl_minutes, ) except Exception as e: - await self.keycloak.delete_agent_client(agent_client_id) + await self.registrar.delete_agent(credentials.client_id) raise RuntimeError(f"Failed to request delegated AC: {e}") # 3. Record in Chronicle @@ -82,7 +77,7 @@ class DelegationManager: agent_type=request.agent_type, agent_model=request.agent_model, agent_did=agent_did, - agent_keycloak_client_id=agent_client_id, + agent_keycloak_client_id=credentials.client_id, delegator_did=delegator_did, delegator_ac_id=request.delegator_ac_id, delegated_ac_id=ac_result.get("context_id", ""), @@ -96,18 +91,19 @@ class DelegationManager: await db.create_delegation(delegation) logger.info( - "Delegation created: %s (%s → %s)", delegation_id, delegator_did, agent_did + "Delegation created: %s (%s → %s) via %s", + delegation_id, delegator_did, agent_did, credentials.idp_backend, ) return DelegationResponse( delegation_id=delegation_id, agent_principal=AgentPrincipal( did=agent_did, - keycloak_client_id=agent_client_id, - display_name=agent_display, + keycloak_client_id=credentials.client_id, + display_name=credentials.agent_display_name, ), delegated_ac=ac_result, - agent_token=kc_result.get("client_secret", ""), + agent_token=credentials.client_secret, expires_at=expires_at.isoformat(), max_commands=scope.max_commands, chronicle_cid=chronicle_cid, @@ -119,7 +115,7 @@ class DelegationManager: return False if delegation.agent_keycloak_client_id: - await self.keycloak.delete_agent_client(delegation.agent_keycloak_client_id) + await self.registrar.delete_agent(delegation.agent_keycloak_client_id) await db.revoke_delegation(delegation_id, reason) await chronicle.delegation_revoked(delegation_id, reason) @@ -128,11 +124,11 @@ class DelegationManager: return True async def cleanup_expired(self) -> int: - """Expire stale delegations and clean up Keycloak clients.""" + """Expire stale delegations and clean up IdP registrations.""" expired = await db.expire_stale() for d in expired: if d.agent_keycloak_client_id: - await self.keycloak.delete_agent_client(d.agent_keycloak_client_id) + await self.registrar.delete_agent(d.agent_keycloak_client_id) await chronicle.delegation_expired(d.delegation_id) if expired: logger.info("Expired %d stale delegations", len(expired)) diff --git a/llm_broker/registrar.py b/llm_broker/registrar.py new file mode 100644 index 0000000..b470638 --- /dev/null +++ b/llm_broker/registrar.py @@ -0,0 +1,36 @@ +"""AgentRegistrar protocol — abstract interface for agent identity registration. + +Implementations: + KeycloakRegistrar — Keycloak Admin REST API (§4.1) + EntraRegistrar — Microsoft Entra Agent ID platform (§4.2) + StubRegistrar — dev mode without a real IdP +""" + +from dataclasses import dataclass +from typing import Protocol, runtime_checkable + + +@dataclass +class AgentCredentials: + """Credentials returned after registering an agent identity.""" + client_id: str + client_secret: str + agent_display_name: str + idp_backend: str # "keycloak" | "entra" | "stub" + + +@runtime_checkable +class AgentRegistrar(Protocol): + async def register_agent( + self, + delegation_id: str, + agent_type: str, + delegator_id: str, + display_name: str, + expires_at: str, + metadata: dict | None = None, + ) -> AgentCredentials: ... + + async def delete_agent(self, client_id: str) -> bool: ... + + async def get_agent_token(self, client_id: str) -> str | None: ... diff --git a/llm_broker/registrar_entra.py b/llm_broker/registrar_entra.py new file mode 100644 index 0000000..e70c97d --- /dev/null +++ b/llm_broker/registrar_entra.py @@ -0,0 +1,153 @@ +"""Entra Agent ID registrar — registers agent identities via Microsoft Graph. + +Implements AgentRegistrar for GCAP-SPEC-LLM-PRINCIPAL-BROKER-0001 §4.2. + +Uses standard Graph application registration with agent metadata tags. +When Entra Agent ID Blueprint APIs reach GA, this driver should be updated +to use the dedicated /agentIdentityBlueprints and /agentIdentities endpoints. +""" + +import logging +from typing import Optional + +import httpx +import msal + +from .registrar import AgentCredentials + +logger = logging.getLogger(__name__) + +GRAPH_API = "https://graph.microsoft.com/v1.0" + + +class EntraRegistrar: + """AgentRegistrar implementation using Microsoft Entra + Graph API.""" + + def __init__( + self, + tenant_id: str, + client_id: str, + client_secret: str, + agent_blueprint_id: str = "", + ): + self.tenant_id = tenant_id + self.client_id = client_id + self.client_secret = client_secret + self.agent_blueprint_id = agent_blueprint_id + self._app = msal.ConfidentialClientApplication( + client_id=self.client_id, + client_credential=self.client_secret, + authority=f"https://login.microsoftonline.com/{self.tenant_id}", + ) + + async def _get_token(self) -> str: + result = self._app.acquire_token_for_client( + scopes=["https://graph.microsoft.com/.default"] + ) + if "access_token" in result: + return result["access_token"] + raise RuntimeError( + f"Entra token error: {result.get('error_description', result.get('error', 'unknown'))}" + ) + + async def _headers(self) -> dict: + token = await self._get_token() + return {"Authorization": f"Bearer {token}", "Content-Type": "application/json"} + + async def register_agent( + self, + delegation_id: str, + agent_type: str, + delegator_id: str, + display_name: str, + expires_at: str, + metadata: dict | None = None, + ) -> AgentCredentials: + headers = await self._headers() + + tags = [ + f"agent_type:{agent_type}", + f"delegation_id:{delegation_id}", + f"delegator:{delegator_id}", + "governed:true", + "HideApp", + ] + if self.agent_blueprint_id: + tags.append(f"blueprint:{self.agent_blueprint_id}") + + app_body = { + "displayName": display_name, + "signInAudience": "AzureADMyOrg", + "tags": tags, + "notes": f"Governed AI agent. Delegator: {delegator_id}. Expires: {expires_at}", + "passwordCredentials": [], + } + + async with httpx.AsyncClient(timeout=15.0) as http: + resp = await http.post(f"{GRAPH_API}/applications", json=app_body, headers=headers) + if resp.status_code == 401: + headers = await self._headers() + resp = await http.post(f"{GRAPH_API}/applications", json=app_body, headers=headers) + resp.raise_for_status() + + app_data = resp.json() + app_id = app_data["appId"] + object_id = app_data["id"] + + # Add client secret with TTL matching delegation expiry + secret_resp = await http.post( + f"{GRAPH_API}/applications/{object_id}/addPassword", + json={ + "passwordCredential": { + "displayName": f"delegation-{delegation_id}", + "endDateTime": expires_at, + } + }, + headers=headers, + ) + secret_resp.raise_for_status() + client_secret = secret_resp.json().get("secretText", "") + + # Create service principal + sp_resp = await http.post( + f"{GRAPH_API}/servicePrincipals", + json={"appId": app_id, "displayName": display_name, "tags": tags}, + headers=headers, + ) + if sp_resp.status_code not in (200, 201, 409): + sp_resp.raise_for_status() + + logger.info("Entra: registered agent %s (appId=%s)", display_name, app_id) + return AgentCredentials( + client_id=app_id, + client_secret=client_secret, + agent_display_name=display_name, + idp_backend="entra", + ) + + async def delete_agent(self, client_id: str) -> bool: + headers = await self._headers() + + async with httpx.AsyncClient(timeout=10.0) as http: + resp = await http.get( + f"{GRAPH_API}/applications", + params={"$filter": f"appId eq '{client_id}'"}, + headers=headers, + ) + resp.raise_for_status() + apps = resp.json().get("value", []) + if not apps: + return False + + object_id = apps[0]["id"] + del_resp = await http.delete( + f"{GRAPH_API}/applications/{object_id}", + headers=headers, + ) + deleted = del_resp.status_code in (200, 204) + if deleted: + logger.info("Entra: deleted agent app %s", client_id) + return deleted + + async def get_agent_token(self, client_id: str) -> str | None: + return None diff --git a/llm_broker/registrar_factory.py b/llm_broker/registrar_factory.py new file mode 100644 index 0000000..b9ac9cd --- /dev/null +++ b/llm_broker/registrar_factory.py @@ -0,0 +1,64 @@ +"""Registrar factory — selects the appropriate AgentRegistrar based on config.""" + +import logging + +from .registrar import AgentRegistrar +from .registrar_stub import StubRegistrar + +logger = logging.getLogger(__name__) + + +def create_registrar(config) -> AgentRegistrar: + """Create the appropriate registrar based on AGENT_REGISTRAR setting.""" + driver = config.agent_registrar + + if driver == "keycloak": + if not config.keycloak_admin_client_secret: + logger.warning("Keycloak secret not configured, using stub") + return StubRegistrar() + from .registrar_keycloak import KeycloakRegistrar + return KeycloakRegistrar( + base_url=config.keycloak_url, + realm=config.keycloak_realm, + client_id=config.keycloak_admin_client_id, + client_secret=config.keycloak_admin_client_secret, + ) + + elif driver == "entra": + if not config.entra_client_secret: + logger.warning("Entra secret not configured, using stub") + return StubRegistrar() + from .registrar_entra import EntraRegistrar + return EntraRegistrar( + tenant_id=config.entra_tenant_id, + client_id=config.entra_client_id, + client_secret=config.entra_client_secret, + agent_blueprint_id=config.entra_agent_blueprint_id, + ) + + elif driver == "auto": + if config.entra_client_secret: + from .registrar_entra import EntraRegistrar + logger.info("Auto-selected Entra registrar") + return EntraRegistrar( + tenant_id=config.entra_tenant_id, + client_id=config.entra_client_id, + client_secret=config.entra_client_secret, + agent_blueprint_id=config.entra_agent_blueprint_id, + ) + elif config.keycloak_admin_client_secret: + from .registrar_keycloak import KeycloakRegistrar + logger.info("Auto-selected Keycloak registrar") + return KeycloakRegistrar( + base_url=config.keycloak_url, + realm=config.keycloak_realm, + client_id=config.keycloak_admin_client_id, + client_secret=config.keycloak_admin_client_secret, + ) + else: + logger.warning("No IdP configured, using stub registrar") + return StubRegistrar() + + else: + logger.warning("Unknown registrar driver: %s, using stub", driver) + return StubRegistrar() diff --git a/llm_broker/keycloak.py b/llm_broker/registrar_keycloak.py similarity index 70% rename from llm_broker/keycloak.py rename to llm_broker/registrar_keycloak.py index b28e9fe..48c0b7b 100644 --- a/llm_broker/keycloak.py +++ b/llm_broker/registrar_keycloak.py @@ -1,7 +1,6 @@ -"""Keycloak Admin API client — ephemeral agent client registration. +"""Keycloak registrar — registers ephemeral agent clients via Admin REST API. -Registers and deletes confidential Keycloak clients for AI agent -delegations per GCAP-SPEC-LLM-PRINCIPAL-BROKER-0001 §4.1. +Implements AgentRegistrar for GCAP-SPEC-LLM-PRINCIPAL-BROKER-0001 §4.1. """ import logging @@ -9,10 +8,14 @@ from typing import Optional import httpx +from .registrar import AgentCredentials + logger = logging.getLogger(__name__) -class KeycloakAdmin: +class KeycloakRegistrar: + """AgentRegistrar implementation using Keycloak Admin REST API.""" + def __init__(self, base_url: str, realm: str, client_id: str, client_secret: str): self.base_url = base_url.rstrip("/") self.realm = realm @@ -39,23 +42,20 @@ class KeycloakAdmin: await self._get_admin_token() return {"Authorization": f"Bearer {self._token}"} - async def register_agent_client( + async def register_agent( self, - client_id: str, - display_name: str, - delegator_did: str, delegation_id: str, agent_type: str, - ) -> dict: - """Register ephemeral Keycloak client for an AI agent.""" - if not self.client_secret: - logger.info("Keycloak not configured — dev mode stub for %s", client_id) - return {"client_id": client_id, "client_secret": f"dev-secret-{delegation_id}", "client_uuid": None} - + delegator_id: str, + display_name: str, + expires_at: str, + metadata: dict | None = None, + ) -> AgentCredentials: headers = await self._headers() + kc_client_id = f"agent-{agent_type}-{delegation_id}" client_rep = { - "clientId": client_id, + "clientId": kc_client_id, "name": display_name, "enabled": True, "serviceAccountsEnabled": True, @@ -64,7 +64,7 @@ class KeycloakAdmin: "protocol": "openid-connect", "attributes": { "agent_type": agent_type, - "delegator_did": delegator_did, + "delegator_did": delegator_id, "delegation_id": delegation_id, }, } @@ -75,7 +75,6 @@ class KeycloakAdmin: json=client_rep, headers=headers, ) - if resp.status_code == 401: headers = {"Authorization": f"Bearer {await self._get_admin_token()}"} resp = await http.post( @@ -83,13 +82,11 @@ class KeycloakAdmin: json=client_rep, headers=headers, ) - resp.raise_for_status() - # Retrieve the generated client secret location = resp.headers.get("Location", "") client_uuid = location.rstrip("/").split("/")[-1] if location else None - client_secret = "" + secret = "" if client_uuid: secret_resp = await http.get( @@ -97,21 +94,17 @@ class KeycloakAdmin: headers=headers, ) if secret_resp.status_code == 200: - client_secret = secret_resp.json().get("value", "") + secret = secret_resp.json().get("value", "") - logger.info("Registered agent client: %s (uuid=%s)", client_id, client_uuid) - return { - "client_id": client_id, - "client_secret": client_secret, - "client_uuid": client_uuid, - } - - async def delete_agent_client(self, client_id: str) -> bool: - """Delete ephemeral agent client on revocation/expiry.""" - if not self.client_secret: - logger.info("Keycloak not configured — dev mode stub delete for %s", client_id) - return True + logger.info("Keycloak: registered agent %s (uuid=%s)", kc_client_id, client_uuid) + return AgentCredentials( + client_id=kc_client_id, + client_secret=secret, + agent_display_name=display_name, + idp_backend="keycloak", + ) + async def delete_agent(self, client_id: str) -> bool: headers = await self._headers() async with httpx.AsyncClient(timeout=10.0) as http: @@ -134,5 +127,8 @@ class KeycloakAdmin: ) deleted = del_resp.status_code in (200, 204) if deleted: - logger.info("Deleted agent client: %s", client_id) + logger.info("Keycloak: deleted agent %s", client_id) return deleted + + async def get_agent_token(self, client_id: str) -> str | None: + return None diff --git a/llm_broker/registrar_stub.py b/llm_broker/registrar_stub.py new file mode 100644 index 0000000..5751419 --- /dev/null +++ b/llm_broker/registrar_stub.py @@ -0,0 +1,35 @@ +"""Stub registrar for development without a real IdP.""" + +import logging + +from .registrar import AgentCredentials + +logger = logging.getLogger(__name__) + + +class StubRegistrar: + """AgentRegistrar stub — returns dev credentials without calling any IdP.""" + + async def register_agent( + self, + delegation_id: str, + agent_type: str, + delegator_id: str, + display_name: str, + expires_at: str, + metadata: dict | None = None, + ) -> AgentCredentials: + logger.info("Stub registrar: register %s for delegation %s", agent_type, delegation_id) + return AgentCredentials( + client_id=f"stub-agent-{agent_type}-{delegation_id}", + client_secret=f"stub-secret-{delegation_id}", + agent_display_name=display_name, + idp_backend="stub", + ) + + async def delete_agent(self, client_id: str) -> bool: + logger.info("Stub registrar: delete %s", client_id) + return True + + async def get_agent_token(self, client_id: str) -> str | None: + return f"stub-token-{client_id}" diff --git a/llm_broker/settings.py b/llm_broker/settings.py index 03c09a4..b29d4ab 100644 --- a/llm_broker/settings.py +++ b/llm_broker/settings.py @@ -21,6 +21,15 @@ class Settings(BaseSettings): keycloak_admin_client_id: str = "llm-broker-admin" keycloak_admin_client_secret: str = "" + # Agent registrar driver: auto | keycloak | entra + agent_registrar: str = "auto" + + # Entra Agent ID + entra_tenant_id: str = "" + entra_client_id: str = "" + entra_client_secret: str = "" + entra_agent_blueprint_id: str = "" + # Chronicle chronicle_webhook_url: Optional[str] = None diff --git a/pyproject.toml b/pyproject.toml index b1a9c1c..53c052c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,6 +16,7 @@ dependencies = [ "sqlmodel>=0.0.19", "aiosqlite>=0.20.0", "structlog>=24.1.0", + "msal>=1.28.0", ] [project.optional-dependencies]