# Copyright 2026 Guildhouse Dev # SPDX-License-Identifier: Apache-2.0 """Bastion dynamic inventory plugin for Ansible. Queries the Bastion broker for managed devices and presents them as Ansible inventory. Devices are grouped by OS, compliance state, and accord scope. Host variables include Bastion-specific metadata with bastion_ prefix. BOUNDARY: This plugin provides inventory and read-only host variables. It does NOT provide credentials -- that is the credential lookup plugin's job. """ from __future__ import annotations import os import json import logging from urllib.request import Request, urlopen from urllib.error import URLError logger = logging.getLogger(__name__) # Credential field names that must NEVER appear as host variables. _CREDENTIAL_FIELDS = frozenset({ "ansible_password", "ansible_ssh_pass", "ansible_ssh_private_key_file", "ansible_become_pass", "ansible_winrm_password", }) DOCUMENTATION = """ name: guildhouse.bastion.bastion plugin_type: inventory short_description: Bastion MDM dynamic inventory description: - Queries Bastion broker for managed devices - Groups by OS, compliance state, accord scope - Provides bastion_* host variables options: bastion_url: description: Bastion broker URL required: true env: - name: BASTION_URL bastion_token: description: Bearer token for Bastion API required: false env: - name: BASTION_TOKEN """ def _fetch_devices(url, token=""): """Fetch device list from Bastion broker.""" headers = {"Content-Type": "application/json"} if token: headers["Authorization"] = f"Bearer {token}" body = json.dumps({"operation": "list_devices", "parameters": {}}).encode() req = Request(f"{url}/connectors/intune/invoke/", data=body, headers=headers, method="POST") try: with urlopen(req, timeout=15) as resp: data = json.loads(resp.read()) if data.get("success"): return data.get("data", []) except URLError as e: logger.warning("Failed to fetch devices from Bastion: %s", e) return [] def build_inventory(devices): """Build Ansible inventory dict from Bastion device list. Returns inventory suitable for ``--list`` output or internal ``parse()`` population. NEVER includes credential variables in host vars. """ inventory = { "_meta": {"hostvars": {}}, "all": {"hosts": [], "children": []}, } groups: dict[str, list[str]] = {} for device in devices: device_name = device.get("device_name", "") device_id = device.get("device_id", "") if not device_name: continue hostname = device_name.lower() inventory["all"]["hosts"].append(hostname) # Host variables (bastion_ prefix, NEVER credentials) hostvars = { "bastion_device_id": device_id, "bastion_compliance_state": device.get("compliance_state", "unknown"), "bastion_os_type": device.get("os_type", "").lower(), "bastion_os_version": device.get("os_version", ""), "bastion_last_sync": device.get("last_sync"), "bastion_user_principal_name": device.get("user_principal_name"), "bastion_entra_device_id": device.get("entra_device_id"), "bastion_management_channel": "intune", } # Safety: ensure no credential fields leak through for field in _CREDENTIAL_FIELDS: hostvars.pop(field, None) inventory["_meta"]["hostvars"][hostname] = hostvars # Group by OS os_type = device.get("os_type", "unknown").lower() os_group = f"os_{os_type}" if os_type else "os_unknown" groups.setdefault(os_group, []).append(hostname) # Group by compliance compliance = device.get("compliance_state", "unknown").lower() comp_group = f"compliance_{compliance}" groups.setdefault(comp_group, []).append(hostname) # Add groups to inventory for group_name, hosts in groups.items(): inventory[group_name] = {"hosts": hosts} if group_name not in inventory["all"].get("children", []): inventory["all"].setdefault("children", []).append(group_name) return inventory