diff --git a/gsap_broker/delegations/registrars/entra.py b/gsap_broker/delegations/registrars/entra.py index d47d6f9..e4061d0 100644 --- a/gsap_broker/delegations/registrars/entra.py +++ b/gsap_broker/delegations/registrars/entra.py @@ -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,37 +53,34 @@ 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) - 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() + 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"] + 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={ - "passwordCredential": { - "displayName": f"delegation-{delegation_id}", - "endDateTime": expires_at, - } - }, - headers=headers, - ) - secret_resp.raise_for_status() - client_secret = secret_resp.json().get("secretText", "") + 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 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() + 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( @@ -123,28 +91,22 @@ 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", []) - if not apps: - return False + except Exception: + 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 + 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 diff --git a/gsap_broker/delegations/registrars/factory.py b/gsap_broker/delegations/registrars/factory.py index 135f44e..8ab7c43 100644 --- a/gsap_broker/delegations/registrars/factory.py +++ b/gsap_broker/delegations/registrars/factory.py @@ -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: diff --git a/gsap_broker/intune/__init__.py b/gsap_broker/intune/__init__.py new file mode 100644 index 0000000..db326d9 --- /dev/null +++ b/gsap_broker/intune/__init__.py @@ -0,0 +1,2 @@ +# Copyright 2026 Guildhouse Dev +# SPDX-License-Identifier: Apache-2.0 diff --git a/gsap_broker/intune/graph_client.py b/gsap_broker/intune/graph_client.py new file mode 100644 index 0000000..cdc22de --- /dev/null +++ b/gsap_broker/intune/graph_client.py @@ -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)