fastapi-gsap/ansible_collection/guildhouse/bastion/plugins/module_utils/bastion_client.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

113 lines
3.9 KiB
Python

# Copyright 2026 Guildhouse Dev
# SPDX-License-Identifier: Apache-2.0
"""Shared HTTP client for Bastion broker API.
Used by all plugins (inventory, credential, callback) to
communicate with the Bastion broker. Handles authentication,
base URL configuration, and error handling.
Configuration via environment variables:
BASTION_URL -- broker base URL (e.g. http://localhost:8000)
BASTION_TOKEN -- bearer token for API authentication
"""
import json
import logging
import os
from urllib.request import Request, urlopen
from urllib.error import URLError
logger = logging.getLogger(__name__)
# Uses stdlib urllib to avoid requiring httpx/requests as a
# dependency for the Ansible collection. Ansible control nodes
# always have Python stdlib available.
class BastionClient:
"""HTTP client for the Bastion broker API."""
def __init__(self, url=None, token=None):
self.url = (url or os.environ.get("BASTION_URL", "http://localhost:8000")).rstrip("/")
self.token = token or os.environ.get("BASTION_TOKEN", "")
def _headers(self):
h = {"Content-Type": "application/json"}
if self.token:
h["Authorization"] = f"Bearer {self.token}"
return h
def _get(self, path, params=None):
"""HTTP GET with auth headers."""
url = f"{self.url}{path}"
if params:
qs = "&".join(f"{k}={v}" for k, v in params.items())
url = f"{url}?{qs}"
req = Request(url, headers=self._headers(), method="GET")
try:
with urlopen(req, timeout=15) as resp:
return json.loads(resp.read())
except URLError as e:
logger.warning("Bastion API GET %s failed: %s", path, e)
return None
def _post(self, path, body=None):
"""HTTP POST with auth headers."""
url = f"{self.url}{path}"
data = json.dumps(body or {}).encode()
req = Request(url, data=data, headers=self._headers(), method="POST")
try:
with urlopen(req, timeout=15) as resp:
return json.loads(resp.read())
except URLError as e:
logger.warning("Bastion API POST %s failed: %s", path, e)
return None
def get_devices(self, filters=None):
"""Query Bastion for managed device inventory."""
result = self._post(
"/connectors/intune/invoke/",
{"operation": "list_devices", "parameters": filters or {}},
)
if result and result.get("success"):
return result.get("data", [])
return []
def get_device_compliance(self, device_id):
"""Get compliance state for a specific device."""
result = self._post(
"/connectors/intune/invoke/",
{"operation": "get_compliance", "parameters": {"device_id": device_id}},
)
if result and result.get("success"):
return result.get("data", {})
return None
def resolve_credential(self, credential_type, target, ac_id=None):
"""Resolve a short-lived credential for a target host.
NOTE: The broker does not yet expose a /credentials/resolve
HTTP endpoint. Returns None for now -- Ansible falls back to
its own credential resolution. The endpoint will be added
when the CredentialResolver gets an HTTP API.
"""
logger.debug(
"Credential resolution not yet available via HTTP API "
"(type=%s, target=%s)", credential_type, target
)
return None
def emit_chronicle(self, event_type, event_data):
"""Emit a Chronicle event via the broker."""
return self._post(
"/connectors/echo/invoke/",
{
"operation": "chronicle_proxy",
"parameters": {"kind": event_type, **event_data},
},
)
def get_ac(self, ac_id):
"""Verify an AC is active."""
return self._get(f"/governance/session/{ac_id}/")