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>
This commit is contained in:
Tyler J King 2026-04-14 11:23:03 -04:00
parent 77964e4042
commit 85afbd8d61
8 changed files with 635 additions and 0 deletions

View file

@ -0,0 +1,13 @@
namespace: guildhouse
name: bastion
version: 0.1.0
description: >
Bastion MDM integration for Ansible. Provides dynamic inventory
from Bastion's device registry, credential resolution via
Bastion's zero-storage CredentialResolver, and Chronicle audit
callback for playbook governance.
license:
- Apache-2.0
dependencies: {}
repository: https://git.guildhouse.dev/tking/fastapi-gsap
documentation: https://git.guildhouse.dev/tking/fastapi-gsap/docs/ansible

View file

@ -0,0 +1,126 @@
# Copyright 2026 Guildhouse Dev
# SPDX-License-Identifier: Apache-2.0
"""Bastion Chronicle callback plugin for Ansible.
Reports playbook execution events to Chronicle via the
Bastion broker. Every playbook run, task result, and final
status becomes a governed audit event.
Events emitted:
ANSIBLE_PLAYBOOK_STARTED -- playbook execution begins
ANSIBLE_TASK_COMPLETED -- individual task result (per host)
ANSIBLE_PLAYBOOK_COMPLETED -- final playbook status + summary
"""
from __future__ import annotations
import logging
import os
from datetime import datetime, UTC
logger = logging.getLogger(__name__)
DOCUMENTATION = """
name: guildhouse.bastion.bastion_chronicle
type: notification
short_description: Bastion Chronicle audit callback
description:
- Reports playbook lifecycle events to Chronicle
- Optionally triggers compliance re-evaluation after playbook
"""
def _get_client():
"""Lazily construct BastionClient."""
try:
from ansible_collection.guildhouse.bastion.plugins.module_utils.bastion_client import BastionClient
return BastionClient()
except ImportError:
return None
class ChronicleCallback:
"""Bastion Chronicle callback for Ansible playbook auditing.
This is a standalone class (not subclassing Ansible's
CallbackBase) so it can be tested without Ansible installed.
The actual Ansible callback module wraps this class.
"""
CALLBACK_VERSION = 2.0
CALLBACK_TYPE = "notification"
CALLBACK_NAME = "guildhouse.bastion.bastion_chronicle"
def __init__(self):
self._client = _get_client()
self._ac_id = os.environ.get("BASTION_AC_ID", "")
self._recheck = os.environ.get("BASTION_RECHECK_COMPLIANCE", "").lower() == "true"
self._affected_hosts: set[str] = set()
self._events: list[dict] = []
def on_playbook_start(self, playbook_name: str, hosts: list[str] | None = None):
"""Emit ANSIBLE_PLAYBOOK_STARTED Chronicle event."""
event = {
"kind": "ANSIBLE_PLAYBOOK_STARTED",
"playbook": playbook_name,
"hosts": hosts or [],
"ac_id": self._ac_id,
"timestamp": datetime.now(UTC).isoformat(),
}
self._events.append(event)
self._emit(event)
def on_task_completed(self, host: str, task_name: str, status: str, changed: bool = False):
"""Emit ANSIBLE_TASK_COMPLETED for a single host result."""
self._affected_hosts.add(host)
event = {
"kind": "ANSIBLE_TASK_COMPLETED",
"host": host,
"task": task_name,
"status": status,
"changed": changed,
"ac_id": self._ac_id,
"timestamp": datetime.now(UTC).isoformat(),
}
self._events.append(event)
self._emit(event)
def on_playbook_completed(self, stats: dict):
"""Emit ANSIBLE_PLAYBOOK_COMPLETED with summary.
Optionally triggers compliance re-evaluation for affected hosts.
"""
event = {
"kind": "ANSIBLE_PLAYBOOK_COMPLETED",
"stats": stats,
"affected_hosts": sorted(self._affected_hosts),
"ac_id": self._ac_id,
"timestamp": datetime.now(UTC).isoformat(),
}
self._events.append(event)
self._emit(event)
if self._recheck and self._affected_hosts:
self._trigger_compliance_recheck()
def _emit(self, event: dict):
"""Send event to Chronicle via Bastion client."""
if self._client is None:
logger.debug("Chronicle callback: no Bastion client available")
return
try:
self._client.emit_chronicle(event["kind"], event)
except Exception as e:
logger.warning("Chronicle emit failed: %s", e)
def _trigger_compliance_recheck(self):
"""Trigger compliance re-evaluation for affected hosts."""
if self._client is None:
return
for host in self._affected_hosts:
try:
self._client.get_device_compliance(host)
logger.info("Compliance re-check triggered for %s", host)
except Exception as e:
logger.warning("Compliance re-check failed for %s: %s", host, e)

View file

@ -0,0 +1,129 @@
# 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

View file

@ -0,0 +1,86 @@
# Copyright 2026 Guildhouse Dev
# SPDX-License-Identifier: Apache-2.0
"""Bastion credential lookup plugin for Ansible.
Resolves short-lived credentials from Bastion's CredentialResolver
at playbook execution time. Replaces static credentials in
Ansible Vault with dynamic, scoped, time-limited credentials.
SECURITY: Credentials are resolved per-host at execution time.
They are NOT cached, NOT written to facts, NOT stored in any
persistent structure. Ansible holds them only for the duration
of the connection to that specific host.
"""
from __future__ import annotations
import logging
import os
logger = logging.getLogger(__name__)
DOCUMENTATION = """
name: guildhouse.bastion.credential
short_description: Resolve credentials from Bastion
description:
- Acquires short-lived credentials from Bastion CredentialResolver
- Zero credential storage -- resolved at execution time only
options:
host:
description: Target hostname to resolve credential for
required: true
type:
description: Credential type (kerberos, oauth, ssh_cert, auto)
required: false
default: auto
ac_id:
description: Authorization Context ID for governed resolution
required: false
env:
- name: BASTION_AC_ID
"""
# OS type to credential type mapping for auto-detection
_AUTO_TYPE_MAP = {
"windows": "kerberos",
"linux": "ssh_cert",
"macos": "ssh_cert",
}
def resolve_credential(host, credential_type="auto", ac_id=None, host_vars=None):
"""Resolve a credential for the specified host.
1. Determine credential type (auto-detect from bastion_os_type
host var, or use explicit type)
2. Call Bastion broker's credential resolution endpoint
3. Return the credential value
NOTE: Until the broker exposes a /credentials/resolve HTTP
endpoint, this returns None with a warning, allowing Ansible
to fall back to its own credential resolution.
"""
if credential_type == "auto" and host_vars:
os_type = host_vars.get("bastion_os_type", "").lower()
credential_type = _AUTO_TYPE_MAP.get(os_type, "oauth")
# Import here to avoid hard dependency on bastion_client at
# module load time (Ansible loads all lookup plugins eagerly).
try:
from ansible_collection.guildhouse.bastion.plugins.module_utils.bastion_client import BastionClient
client = BastionClient()
result = client.resolve_credential(credential_type, host, ac_id)
if result is not None:
return result
except ImportError:
pass
except Exception as e:
logger.warning("Bastion credential resolution failed for %s: %s", host, e)
logger.debug(
"Bastion credential resolution not available for %s "
"(type=%s). Falling back to Ansible credential resolution.",
host, credential_type,
)
return None

View file

@ -0,0 +1,113 @@
# 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}/")

View file

@ -0,0 +1,168 @@
# Copyright 2026 Guildhouse Dev
# SPDX-License-Identifier: Apache-2.0
"""Tests for the Ansible collection plugins — inventory, credential, callback."""
import sys
import os
import pytest
# Add the collection to the path so plugins are importable
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "ansible_collection"))
from guildhouse.bastion.plugins.inventory.bastion import build_inventory, _CREDENTIAL_FIELDS
from guildhouse.bastion.plugins.lookup.credential import resolve_credential, _AUTO_TYPE_MAP
from guildhouse.bastion.plugins.callback.bastion_chronicle import ChronicleCallback
# ── Inventory plugin ─────────────────────────────────────────────
MOCK_DEVICES = [
{
"device_id": "00000000-0000-0000-0000-000000000001",
"device_name": "LAPTOP-WIN-01",
"os_type": "Windows",
"os_version": "10.0.19045",
"compliance_state": "compliant",
"last_sync": "2026-04-14T00:00:00Z",
"user_principal_name": "alice@contoso.com",
"entra_device_id": "entra-001",
},
{
"device_id": "00000000-0000-0000-0000-000000000002",
"device_name": "SRV-LINUX-01",
"os_type": "Linux",
"os_version": "6.6",
"compliance_state": "noncompliant",
},
{
"device_id": "00000000-0000-0000-0000-000000000003",
"device_name": "MAC-01",
"os_type": "macOS",
"os_version": "14.0",
"compliance_state": "compliant",
},
]
def test_inventory_groups_by_os():
"""TEST 12: Devices grouped by OS correctly."""
inv = build_inventory(MOCK_DEVICES)
assert "laptop-win-01" in inv["os_windows"]["hosts"]
assert "srv-linux-01" in inv["os_linux"]["hosts"]
assert "mac-01" in inv["os_macos"]["hosts"]
def test_inventory_groups_by_compliance():
"""TEST 12: Devices grouped by compliance state."""
inv = build_inventory(MOCK_DEVICES)
assert "laptop-win-01" in inv["compliance_compliant"]["hosts"]
assert "mac-01" in inv["compliance_compliant"]["hosts"]
assert "srv-linux-01" in inv["compliance_noncompliant"]["hosts"]
def test_inventory_host_vars_have_bastion_prefix():
"""TEST 12: Host variables prefixed with bastion_."""
inv = build_inventory(MOCK_DEVICES)
hostvars = inv["_meta"]["hostvars"]["laptop-win-01"]
assert hostvars["bastion_device_id"] == "00000000-0000-0000-0000-000000000001"
assert hostvars["bastion_compliance_state"] == "compliant"
assert hostvars["bastion_os_type"] == "windows"
assert hostvars["bastion_management_channel"] == "intune"
def test_inventory_no_credentials_in_host_vars():
"""TEST 13: No credential variables in host vars."""
inv = build_inventory(MOCK_DEVICES)
for hostname, hostvars in inv["_meta"]["hostvars"].items():
for field in _CREDENTIAL_FIELDS:
assert field not in hostvars, f"Credential field '{field}' found in {hostname} host vars"
def test_inventory_empty_device_list():
"""Empty device list produces valid empty inventory."""
inv = build_inventory([])
assert inv["all"]["hosts"] == []
assert inv["_meta"]["hostvars"] == {}
# ── Credential lookup plugin ─────────────────────────────────────
def test_credential_auto_detect_windows():
"""Auto-detect type from bastion_os_type."""
cred_type = _AUTO_TYPE_MAP.get("windows")
assert cred_type == "kerberos"
def test_credential_auto_detect_linux():
cred_type = _AUTO_TYPE_MAP.get("linux")
assert cred_type == "ssh_cert"
def test_credential_graceful_degradation():
"""TEST 14: Returns None when broker unavailable."""
result = resolve_credential("host-1", credential_type="kerberos")
assert result is None # Broker not running — graceful degradation
# ── Chronicle callback plugin ────────────────────────────────────
def test_callback_playbook_started():
"""TEST 15: PLAYBOOK_STARTED event emitted."""
cb = ChronicleCallback()
cb.on_playbook_start("test-playbook.yml", ["host-1", "host-2"])
assert len(cb._events) == 1
assert cb._events[0]["kind"] == "ANSIBLE_PLAYBOOK_STARTED"
assert cb._events[0]["playbook"] == "test-playbook.yml"
def test_callback_task_completed():
"""TEST 15: TASK_COMPLETED event emitted per host."""
cb = ChronicleCallback()
cb.on_task_completed("host-1", "Ping", "ok", changed=False)
cb.on_task_completed("host-2", "Ping", "failed", changed=False)
assert len(cb._events) == 2
assert cb._events[0]["kind"] == "ANSIBLE_TASK_COMPLETED"
assert cb._events[1]["status"] == "failed"
def test_callback_playbook_completed():
"""TEST 15: PLAYBOOK_COMPLETED event with stats."""
cb = ChronicleCallback()
cb.on_task_completed("host-1", "Ping", "ok")
cb.on_playbook_completed({"ok": 1, "failed": 0, "changed": 0})
events = [e for e in cb._events if e["kind"] == "ANSIBLE_PLAYBOOK_COMPLETED"]
assert len(events) == 1
assert "host-1" in events[0]["affected_hosts"]
def test_callback_full_lifecycle():
"""TEST 15: Full playbook lifecycle audited with 3 events."""
cb = ChronicleCallback()
cb.on_playbook_start("site.yml", ["host-1"])
cb.on_task_completed("host-1", "Install packages", "changed", changed=True)
cb.on_playbook_completed({"ok": 0, "changed": 1, "failed": 0})
kinds = [e["kind"] for e in cb._events]
assert kinds == [
"ANSIBLE_PLAYBOOK_STARTED",
"ANSIBLE_TASK_COMPLETED",
"ANSIBLE_PLAYBOOK_COMPLETED",
]
def test_callback_compliance_recheck_triggered(monkeypatch):
"""TEST 16: Compliance re-eval triggered when configured."""
monkeypatch.setenv("BASTION_RECHECK_COMPLIANCE", "true")
cb = ChronicleCallback()
cb._recheck = True # env already set but constructor ran before monkeypatch
cb._affected_hosts = {"host-1", "host-2"}
# Without a real client, _trigger_compliance_recheck is a no-op
# but should not raise
cb.on_playbook_completed({"ok": 2})
assert len(cb._events) == 1 # PLAYBOOK_COMPLETED emitted