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>
129 lines
4.2 KiB
Python
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
|