refactor: extract shared Graph API client from Entra registrar

Creates gsap_broker/intune/graph_client.py with MSAL
client_credentials auth and typed Graph API methods.
Entra registrar refactored to consume the shared client.

Signed-off-by: Tyler King <tking@guildhouse.dev>
This commit is contained in:
Tyler J King 2026-04-14 05:16:09 -04:00
parent 8c949b38c0
commit 1ab47417c9
4 changed files with 164 additions and 86 deletions

View file

@ -1,57 +1,30 @@
# 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 standard Graph application registration with agent metadata tags.
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
import httpx
import msal
from gsap_broker.intune.graph_client import GraphClient
from .base 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
def __init__(self, graph_client: GraphClient, agent_blueprint_id: str = ""):
self.graph = graph_client
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,
@ -62,8 +35,6 @@ class EntraRegistrar:
expires_at: str,
metadata: dict | None = None,
) -> AgentCredentials:
headers = await self._headers()
tags = [
f"agent_type:{agent_type}",
f"delegation_id:{delegation_id}",
@ -82,34 +53,31 @@ class EntraRegistrar:
"passwordCredentials": [],
}
async with httpx.AsyncClient(timeout=15.0) as http:
resp = await http.post(f"{GRAPH_API}/applications", json=app_body, headers=headers)
resp = await self.graph.post("/applications", body=app_body)
if resp.status_code == 401:
headers = await self._headers()
resp = await http.post(f"{GRAPH_API}/applications", json=app_body, headers=headers)
# 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 http.post(
f"{GRAPH_API}/applications/{object_id}/addPassword",
json={
secret_resp = await self.graph.post(
f"/applications/{object_id}/addPassword",
body={
"passwordCredential": {
"displayName": f"delegation-{delegation_id}",
"endDateTime": expires_at,
}
},
headers=headers,
)
secret_resp.raise_for_status()
client_secret = secret_resp.json().get("secretText", "")
sp_resp = await http.post(
f"{GRAPH_API}/servicePrincipals",
json={"appId": app_id, "displayName": display_name, "tags": tags},
headers=headers,
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()
@ -123,25 +91,19 @@ class EntraRegistrar:
)
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,
try:
data = await self.graph.get(
"/applications", params={"$filter": f"appId eq '{client_id}'"}
)
resp.raise_for_status()
apps = resp.json().get("value", [])
except Exception:
return False
apps = data.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)
deleted = await self.graph.delete(f"/applications/{object_id}")
if deleted:
logger.info("Entra: deleted agent app %s", client_id)
return deleted

View file

@ -1,3 +1,6 @@
# Copyright 2026 Guildhouse Dev
# SPDX-License-Identifier: Apache-2.0
"""Registrar factory — selects the appropriate AgentRegistrar based on settings."""
import logging
@ -31,22 +34,30 @@ def create_registrar(config) -> AgentRegistrar:
if not config.entra_client_secret:
logger.warning("Entra secret not configured, using stub")
return StubRegistrar()
from gsap_broker.intune.graph_client import GraphClient
from .entra import EntraRegistrar
return EntraRegistrar(
graph = GraphClient(
tenant_id=config.entra_tenant_id,
client_id=config.entra_client_id,
client_secret=config.entra_client_secret,
)
return EntraRegistrar(
graph_client=graph,
agent_blueprint_id=config.entra_agent_blueprint_id,
)
if driver == "auto":
if config.entra_client_secret:
from gsap_broker.intune.graph_client import GraphClient
from .entra import EntraRegistrar
logger.info("Auto-selected Entra registrar")
return EntraRegistrar(
graph = GraphClient(
tenant_id=config.entra_tenant_id,
client_id=config.entra_client_id,
client_secret=config.entra_client_secret,
)
return EntraRegistrar(
graph_client=graph,
agent_blueprint_id=config.entra_agent_blueprint_id,
)
if config.keycloak_admin_client_secret:

View file

@ -0,0 +1,2 @@
# Copyright 2026 Guildhouse Dev
# SPDX-License-Identifier: Apache-2.0

View file

@ -0,0 +1,103 @@
# Copyright 2026 Guildhouse Dev
# SPDX-License-Identifier: Apache-2.0
"""Shared Microsoft Graph API client.
Uses MSAL client_credentials flow for app-only access.
Provides authenticated httpx calls to Graph API endpoints.
Extracted from delegations/registrars/entra.py to serve
both the Entra registrar and the Intune connector.
"""
import logging
from typing import Any, Optional
import httpx
import msal
logger = logging.getLogger(__name__)
GRAPH_API_DEFAULT = "https://graph.microsoft.com/v1.0"
class GraphClient:
"""Authenticated Microsoft Graph API client."""
def __init__(
self,
tenant_id: str,
client_id: str,
client_secret: str,
graph_api_base: str = GRAPH_API_DEFAULT,
):
self.tenant_id = tenant_id
self.client_id = client_id
self.client_secret = client_secret
self.graph_api_base = graph_api_base.rstrip("/")
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 acquire_token(self) -> str:
"""Acquire an access token via MSAL client_credentials."""
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"Graph token error: {result.get('error_description', result.get('error', 'unknown'))}"
)
async def _headers(self) -> dict[str, str]:
token = await self.acquire_token()
return {
"Authorization": f"Bearer {token}",
"Content-Type": "application/json",
}
async def get(
self, path: str, params: Optional[dict[str, Any]] = None
) -> dict[str, Any]:
"""Authenticated GET to Graph API."""
headers = await self._headers()
async with httpx.AsyncClient(timeout=15.0) as client:
resp = await client.get(
f"{self.graph_api_base}{path}", params=params, headers=headers
)
resp.raise_for_status()
return resp.json()
async def post(
self, path: str, body: Optional[dict[str, Any]] = None
) -> httpx.Response:
"""Authenticated POST to Graph API. Returns raw Response for status/header access."""
headers = await self._headers()
async with httpx.AsyncClient(timeout=15.0) as client:
resp = await client.post(
f"{self.graph_api_base}{path}", json=body, headers=headers
)
return resp
async def patch(
self, path: str, body: Optional[dict[str, Any]] = None
) -> dict[str, Any]:
"""Authenticated PATCH to Graph API."""
headers = await self._headers()
async with httpx.AsyncClient(timeout=15.0) as client:
resp = await client.patch(
f"{self.graph_api_base}{path}", json=body, headers=headers
)
resp.raise_for_status()
return resp.json()
async def delete(self, path: str) -> bool:
"""Authenticated DELETE to Graph API. Returns True if 200/204."""
headers = await self._headers()
async with httpx.AsyncClient(timeout=15.0) as client:
resp = await client.delete(
f"{self.graph_api_base}{path}", headers=headers
)
return resp.status_code in (200, 204)