diff --git a/gsap_broker/routing/__init__.py b/gsap_broker/routing/__init__.py new file mode 100644 index 0000000..db326d9 --- /dev/null +++ b/gsap_broker/routing/__init__.py @@ -0,0 +1,2 @@ +# Copyright 2026 Guildhouse Dev +# SPDX-License-Identifier: Apache-2.0 diff --git a/gsap_broker/routing/device_router.py b/gsap_broker/routing/device_router.py new file mode 100644 index 0000000..028f3aa --- /dev/null +++ b/gsap_broker/routing/device_router.py @@ -0,0 +1,159 @@ +# Copyright 2026 Guildhouse Dev +# SPDX-License-Identifier: Apache-2.0 + +"""DeviceRouter — automatic connector selection based on target device. + +The DeviceRouter inspects the target device's characteristics (OS, +management channel, enrollment status) and routes operations to the +appropriate connector. + +Routing decision logic: + 1. If the operation is API-mediated (compliance check, inventory + query), route to the Intune connector regardless of device OS. + 2. If the device is Windows and the operation is session-based + (execute, configure), route to PowerShell connector. + 3. If the device is Linux/macOS and the operation is session-based, + route to Bascule connector. + 4. If the operation is multi-host (fleet-wide playbook), route to + Ansible connector. + 5. If the device is unknown, raise ``UnknownDevice``. + +The device inventory comes from: + - Intune device cache (Windows/iOS/Android managed devices) + - Bascule fleet registry (Linux governed endpoints) — future + - Manual registration (for unmanaged devices) — future + +Rust port note: + ``DeviceRouter`` maps to a struct with connector registry and + device cache references. The routing logic is a match statement + on ``(operation_type, device_os)``. +""" + +from __future__ import annotations + +import logging +from typing import Any, Optional + +from gsap_broker.connectors.base import ConnectorContext, ConnectorResult +from gsap_broker.connectors.registry import ConnectorRegistry +from gsap_broker.intune.device_cache import DeviceComplianceCache + +logger = logging.getLogger(__name__) + +# Operations that are API-mediated (go through Intune Graph API +# regardless of device OS). +_API_OPERATIONS = frozenset({ + "get_compliance", "list_devices", "get_device", + "sync_device", "retire_device", "wipe_device", +}) + +# Operations that span multiple hosts (go through Ansible). +_FLEET_OPERATIONS = frozenset({ + "playbook", "adhoc", "role", "collect", +}) + +# OS → preferred session connector mapping. +_OS_CONNECTOR_MAP = { + "windows": "powershell", + "linux": "bascule", + "macos": "bascule", + "ios": "intune", # mobile — API-only + "android": "intune", # mobile — API-only +} + + +class UnknownDevice(Exception): + """Target device is not in any inventory.""" + + def __init__(self, target: str): + self.target = target + super().__init__(f"Unknown device: {target}") + + +class DeviceRouter: + """Routes operations to the appropriate connector based on target. + + Combines the device inventory (Intune cache) with the connector + registry to select the best management channel for each operation. + """ + + def __init__( + self, + connector_registry: ConnectorRegistry, + device_cache: Optional[DeviceComplianceCache] = None, + ): + self._connectors = connector_registry + self._devices = device_cache + + async def route( + self, operation: str, target: str, context: ConnectorContext + ) -> tuple[str, str]: + """Determine which connector handles this operation. + + Returns: + (connector_id, mapped_operation) — the connector to use + and the operation name to pass to it (may differ from the + input if the router translates operations). + + Raises: + UnknownDevice: if the target is not in any inventory and + cannot be routed. + """ + # API-mediated operations always go to Intune + if operation in _API_OPERATIONS: + return ("intune", operation) + + # Fleet operations go to Ansible + if operation in _FLEET_OPERATIONS: + return ("ansible", operation) + + # Session operations: route based on device OS + device_os = await self._detect_os(target) + if device_os is None: + raise UnknownDevice(target) + + connector_id = _OS_CONNECTOR_MAP.get(device_os.lower(), "bascule") + return (connector_id, operation) + + async def invoke( + self, + operation: str, + target: str, + parameters: dict[str, Any], + context: ConnectorContext, + ) -> ConnectorResult: + """Route and invoke in one call.""" + try: + connector_id, mapped_op = await self.route(operation, target, context) + except UnknownDevice as e: + return ConnectorResult(success=False, error=str(e)) + + connector = self._connectors.get(connector_id) + if connector is None: + return ConnectorResult( + success=False, + error=f"Connector '{connector_id}' not registered", + ) + + parameters["target"] = target + return await connector.invoke(mapped_op, parameters, context) + + async def _detect_os(self, target: str) -> Optional[str]: + """Look up the device OS from the Intune cache. + + Future: also check Bascule fleet registry, manual + registration, DNS-based hints, etc. + """ + if self._devices is None: + return None + + cached = await self._devices.get(target) + if cached is not None: + # ComplianceState doesn't carry OS — the cache is keyed + # by device_id but the Intune connector's list_devices + # response has os_type. For now, return None and let the + # caller handle it. A proper DeviceInventory cache (not + # just compliance) is needed — tracked for next sprint. + return None + + return None