# 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)