# Copyright 2026 Guildhouse Dev # SPDX-License-Identifier: Apache-2.0 """Intune device management connector — governed Graph API invocation. Implements ConnectorPlugin for Intune Graph API operations. Every invocation requires an active AC and emits a Chronicle CONNECTOR_INVOKED event via the ConnectorRuntime. """ from __future__ import annotations import logging from datetime import datetime, UTC from typing import Any import re from gsap_broker.connectors.base import ( CAP_MUTATE, CAP_PROPOSE, CAP_READ, ConnectorContext, ConnectorPlugin, ConnectorResult, ) from gsap_broker.intune.device_cache import DeviceComplianceCache from gsap_broker.intune.graph_client import GraphClient from gsap_broker.models.intune import ComplianceState, DeviceSummary logger = logging.getLogger(__name__) _GRAPH_DEVICES = "/deviceManagement/managedDevices" # Fix H-5: device_id must be a UUID to prevent path traversal _UUID_RE = re.compile( r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$", re.IGNORECASE, ) def _validate_device_id(device_id: str) -> str: """Fix H-5: validate device_id as UUID before Graph API URL interpolation.""" if not device_id: raise ValueError("device_id required") if not _UUID_RE.match(device_id): raise ValueError(f"Invalid device_id: must be UUID format, got '{device_id}'") return device_id class IntuneConnector(ConnectorPlugin): connector_id = "intune" corpus_entry_cid = "sha256:intune-connector-v1" capability_mask = 0x7 # READ | PROPOSE | MUTATE declared_endpoints = [ "graph.microsoft.com/v1.0/deviceManagement/managedDevices", ] accord_template = "device-management" gsap_required = True chronicle_enabled = True # Fix C-6: per-operation capability requirements operation_capabilities = { "list_devices": CAP_READ, "get_device": CAP_READ, "get_compliance": CAP_READ, "sync_device": CAP_PROPOSE, "remote_lock": CAP_MUTATE, "retire_device": CAP_MUTATE, "wipe_device": CAP_MUTATE, } def __init__(self, graph_client: GraphClient, cache: DeviceComplianceCache | None = None): self.graph = graph_client self.cache = cache or DeviceComplianceCache() async def invoke( self, operation: str, parameters: dict[str, Any], context: ConnectorContext ) -> ConnectorResult: """Route to the appropriate Graph API call.""" handlers = { "list_devices": self._list_devices, "get_device": self._get_device, "get_compliance": self._get_compliance, "sync_device": self._sync_device, "remote_lock": self._remote_lock, "retire_device": self._retire_device, "wipe_device": self._wipe_device, } handler = handlers.get(operation) if handler is None: return ConnectorResult( success=False, error=f"Unknown operation: {operation}" ) try: return await handler(parameters, context) except Exception as e: logger.error("Intune connector error: %s %s", operation, e) return ConnectorResult(success=False, error=str(e)) def health_check(self) -> bool: # Synchronous check — can't call async Graph API here. # Return True if graph client is configured. return bool(self.graph.tenant_id and self.graph.client_id) # ── READ operations ────────────────────────────────────────── async def _list_devices( self, params: dict[str, Any], ctx: ConnectorContext ) -> ConnectorResult: top = params.get("top", 50) select = params.get("select", "id,deviceName,operatingSystem,osVersion,complianceState,lastSyncDateTime,userPrincipalName,azureADDeviceId") data = await self.graph.get(_GRAPH_DEVICES, params={"$top": top, "$select": select}) devices = [ DeviceSummary( device_id=d["id"], device_name=d.get("deviceName", ""), os_type=d.get("operatingSystem", ""), os_version=d.get("osVersion", ""), compliance_state=d.get("complianceState", "unknown"), last_sync=d.get("lastSyncDateTime"), user_principal_name=d.get("userPrincipalName"), entra_device_id=d.get("azureADDeviceId"), ).model_dump(mode="json") for d in data.get("value", []) ] return ConnectorResult(success=True, data=devices) async def _get_device( self, params: dict[str, Any], ctx: ConnectorContext ) -> ConnectorResult: try: device_id = _validate_device_id(params.get("device_id", "")) except ValueError as e: return ConnectorResult(success=False, error=str(e)) data = await self.graph.get(f"{_GRAPH_DEVICES}/{device_id}") return ConnectorResult(success=True, data=data) async def _get_compliance( self, params: dict[str, Any], ctx: ConnectorContext ) -> ConnectorResult: try: device_id = _validate_device_id(params.get("device_id", "")) except ValueError as e: return ConnectorResult(success=False, error=str(e)) # Check cache first cached = await self.cache.get(device_id) if cached is not None: return ConnectorResult(success=True, data=cached.model_dump(mode="json")) # Fetch from Graph API data = await self.graph.get( f"{_GRAPH_DEVICES}/{device_id}", params={"$select": "id,complianceState,lastSyncDateTime,complianceGracePeriodExpirationDateTime"}, ) raw_state = data.get("complianceState", "unknown") state = ComplianceState( device_id=device_id, compliant=raw_state == "compliant", state=raw_state, last_evaluated=datetime.now(UTC), ) await self.cache.set(device_id, state) return ConnectorResult(success=True, data=state.model_dump(mode="json")) # ── PROPOSE operations ─────────────────────────────────────── async def _sync_device( self, params: dict[str, Any], ctx: ConnectorContext ) -> ConnectorResult: try: device_id = _validate_device_id(params.get("device_id", "")) except ValueError as e: return ConnectorResult(success=False, error=str(e)) resp = await self.graph.post(f"{_GRAPH_DEVICES}/{device_id}/syncDevice") if resp.status_code in (200, 204): await self.cache.invalidate(device_id) return ConnectorResult(success=True, data={"synced": True}) return ConnectorResult(success=False, error=f"Sync failed: HTTP {resp.status_code}") # ── MUTATE operations ──────────────────────────────────────── async def _remote_lock( self, params: dict[str, Any], ctx: ConnectorContext ) -> ConnectorResult: try: device_id = _validate_device_id(params.get("device_id", "")) except ValueError as e: return ConnectorResult(success=False, error=str(e)) resp = await self.graph.post(f"{_GRAPH_DEVICES}/{device_id}/remoteLock") if resp.status_code in (200, 204): return ConnectorResult(success=True, data={"locked": True}) return ConnectorResult(success=False, error=f"Lock failed: HTTP {resp.status_code}") async def _retire_device( self, params: dict[str, Any], ctx: ConnectorContext ) -> ConnectorResult: try: device_id = _validate_device_id(params.get("device_id", "")) except ValueError as e: return ConnectorResult(success=False, error=str(e)) resp = await self.graph.post(f"{_GRAPH_DEVICES}/{device_id}/retire") if resp.status_code in (200, 204): return ConnectorResult(success=True, data={"retired": True}) return ConnectorResult(success=False, error=f"Retire failed: HTTP {resp.status_code}") async def _wipe_device( self, params: dict[str, Any], ctx: ConnectorContext ) -> ConnectorResult: try: device_id = _validate_device_id(params.get("device_id", "")) except ValueError as e: return ConnectorResult(success=False, error=str(e)) resp = await self.graph.post(f"{_GRAPH_DEVICES}/{device_id}/wipe") if resp.status_code in (200, 204): return ConnectorResult(success=True, data={"wiped": True}) return ConnectorResult(success=False, error=f"Wipe failed: HTTP {resp.status_code}")