fastapi-gsap/ansible_collection/guildhouse/bastion/plugins/inventory/bastion.py
Tyler J King 85afbd8d61 feat(ansible): add guildhouse.bastion Ansible Galaxy collection
Dynamic inventory plugin — queries Bastion for managed devices,
groups by OS and compliance state, bastion_* host vars, zero
credentials in inventory.

Credential lookup plugin — resolves short-lived credentials from
Bastion's CredentialResolver at execution time. Graceful
degradation when broker unavailable.

Chronicle callback plugin — reports playbook lifecycle events
(started, task completed, completed) to Chronicle. Optionally
triggers compliance re-evaluation after playbook completion.

Shared BastionClient for all plugins using stdlib urllib.

Signed-off-by: Tyler King <tking@guildhouse.dev>
2026-04-14 11:23:03 -04:00

129 lines
4.2 KiB
Python

# 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