# Copyright 2026 Guildhouse Dev # SPDX-License-Identifier: Apache-2.0 """Entra Agent ID registrar — registers agent identities via Microsoft Graph. Implements AgentRegistrar for GCAP-SPEC-LLM-PRINCIPAL-BROKER-0001 §4.2. Uses the shared GraphClient for authenticated Graph API access. When Entra Agent ID Blueprint APIs reach GA, this driver should be updated to use the dedicated /agentIdentityBlueprints and /agentIdentities endpoints. """ import logging from gsap_broker.intune.graph_client import GraphClient from .base import AgentCredentials logger = logging.getLogger(__name__) class EntraRegistrar: """AgentRegistrar implementation using Microsoft Entra + Graph API.""" def __init__(self, graph_client: GraphClient, agent_blueprint_id: str = ""): self.graph = graph_client self.agent_blueprint_id = agent_blueprint_id 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: 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": [], } resp = await self.graph.post("/applications", body=app_body) if resp.status_code == 401: # Token expired between construction and call — retry once. resp = await self.graph.post("/applications", body=app_body) resp.raise_for_status() app_data = resp.json() app_id = app_data["appId"] object_id = app_data["id"] secret_resp = await self.graph.post( f"/applications/{object_id}/addPassword", body={ "passwordCredential": { "displayName": f"delegation-{delegation_id}", "endDateTime": expires_at, } }, ) secret_resp.raise_for_status() client_secret = secret_resp.json().get("secretText", "") sp_resp = await self.graph.post( "/servicePrincipals", body={"appId": app_id, "displayName": display_name, "tags": tags}, ) 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: try: data = await self.graph.get( "/applications", params={"$filter": f"appId eq '{client_id}'"} ) except Exception: return False apps = data.get("value", []) if not apps: return False object_id = apps[0]["id"] deleted = await self.graph.delete(f"/applications/{object_id}") 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