feat(templates): add template system — manifest, policy, loader, registries
bastion.toml manifest parser with variable validation and dependency declarations. Declarative compliance policy schema with per-platform check implementations. Template loader with variable substitution (Bastion-owned files only — never touches Ansible/Terraform). PolicyRegistry and AccordRegistry with builtin fallbacks. BOUNDARY: loader never touches automation framework files. Signed-off-by: Tyler King <tking@guildhouse.dev>
This commit is contained in:
parent
d62974f1b7
commit
77964e4042
10 changed files with 741 additions and 0 deletions
2
gsap_broker/templates/__init__.py
Normal file
2
gsap_broker/templates/__init__.py
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
# Copyright 2026 Guildhouse Dev
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
181
gsap_broker/templates/loader.py
Normal file
181
gsap_broker/templates/loader.py
Normal file
|
|
@ -0,0 +1,181 @@
|
|||
# Copyright 2026 Guildhouse Dev
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
"""Template repository loader.
|
||||
|
||||
Loads a template from a local directory (already cloned/forked).
|
||||
Parses the manifest, validates variables, resolves dependencies
|
||||
(by reading their manifests -- actual git cloning is a separate
|
||||
concern), and loads all content into the broker's registries.
|
||||
|
||||
IMPORTANT: The loader does NOT parse or template automation
|
||||
framework files (Ansible, Terraform, Salt). It only processes
|
||||
Bastion-owned files in Bastion-owned directories.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
import subprocess
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import tomllib
|
||||
|
||||
from gsap_broker.templates.manifest import TemplateManifest
|
||||
from gsap_broker.templates.policy import CompliancePolicy
|
||||
from gsap_broker.templates.registry import AccordRegistry, PolicyRegistry
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Bastion-owned directories where variable substitution is applied.
|
||||
# Files outside these directories are NEVER modified.
|
||||
_BASTION_OWNED_DIRS = frozenset({"policies", "accords", "harnesses", "hbom", "dashboards"})
|
||||
|
||||
|
||||
@dataclass
|
||||
class LoadResult:
|
||||
"""Result of loading a template."""
|
||||
template_name: str = ""
|
||||
policies_loaded: list[str] = field(default_factory=list)
|
||||
accords_loaded: list[str] = field(default_factory=list)
|
||||
warnings: list[str] = field(default_factory=list)
|
||||
errors: list[str] = field(default_factory=list)
|
||||
provenance: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
@property
|
||||
def success(self) -> bool:
|
||||
return len(self.errors) == 0
|
||||
|
||||
|
||||
class TemplateLoader:
|
||||
"""Loads bastion.toml template repos into broker registries."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
policy_registry: PolicyRegistry,
|
||||
accord_registry: AccordRegistry,
|
||||
):
|
||||
self._policies = policy_registry
|
||||
self._accords = accord_registry
|
||||
|
||||
def load(self, template_dir: str | Path, variables: dict[str, Any]) -> LoadResult:
|
||||
"""Load a template from a local directory.
|
||||
|
||||
1. Parse bastion.toml
|
||||
2. Validate required variables are provided
|
||||
3. Substitute variables into Bastion-owned files ONLY
|
||||
4. Parse and load policies into policy registry
|
||||
5. Parse and load accords into accord registry
|
||||
6. Record template provenance (git commit hash if available)
|
||||
|
||||
Returns LoadResult with loaded items and any warnings.
|
||||
"""
|
||||
root = Path(template_dir)
|
||||
result = LoadResult()
|
||||
|
||||
# 1. Parse manifest
|
||||
manifest_path = root / "bastion.toml"
|
||||
if not manifest_path.exists():
|
||||
result.errors.append(f"No bastion.toml found in {root}")
|
||||
return result
|
||||
|
||||
manifest = TemplateManifest.from_toml(manifest_path)
|
||||
result.template_name = manifest.template.name
|
||||
|
||||
# 2. Validate variables
|
||||
# Apply defaults first
|
||||
effective_vars = {}
|
||||
for name, var in manifest.variables.items():
|
||||
if name in variables:
|
||||
effective_vars[name] = variables[name]
|
||||
elif var.default is not None:
|
||||
effective_vars[name] = var.default
|
||||
errors = manifest.validate_variables(effective_vars)
|
||||
if errors:
|
||||
result.errors.extend(errors)
|
||||
return result
|
||||
|
||||
# 3. Provenance
|
||||
result.provenance = self._compute_provenance(root)
|
||||
|
||||
# 4. Load policies
|
||||
policies_dir = root / manifest.contents.policies
|
||||
if policies_dir.is_dir():
|
||||
for f in sorted(policies_dir.glob("*.toml")):
|
||||
try:
|
||||
content = self._substitute_variables(
|
||||
f.read_text(), effective_vars, manifest.variables
|
||||
)
|
||||
# Parse from substituted content
|
||||
data = tomllib.loads(content)
|
||||
policy = CompliancePolicy.model_validate(data)
|
||||
self._policies.register(policy, result.provenance)
|
||||
result.policies_loaded.append(policy.name)
|
||||
except Exception as e:
|
||||
result.warnings.append(f"Failed to load policy {f.name}: {e}")
|
||||
|
||||
# 5. Load accords
|
||||
accords_dir = root / manifest.contents.accords
|
||||
if accords_dir.is_dir():
|
||||
for f in sorted(accords_dir.glob("*.toml")):
|
||||
try:
|
||||
content = self._substitute_variables(
|
||||
f.read_text(), effective_vars, manifest.variables
|
||||
)
|
||||
data = tomllib.loads(content)
|
||||
name = data.get("name", f.stem)
|
||||
self._accords.register(name, data, result.provenance)
|
||||
result.accords_loaded.append(name)
|
||||
except Exception as e:
|
||||
result.warnings.append(f"Failed to load accord {f.name}: {e}")
|
||||
|
||||
return result
|
||||
|
||||
def _substitute_variables(
|
||||
self, content: str, values: dict[str, Any], var_defs: dict
|
||||
) -> str:
|
||||
"""Replace ${variable_name} in content with values.
|
||||
|
||||
ONLY called on Bastion-owned TOML files.
|
||||
NEVER on Ansible/Terraform/script files.
|
||||
Warns on unresolved optional variables.
|
||||
"""
|
||||
def replacer(match: re.Match) -> str:
|
||||
var_name = match.group(1)
|
||||
if var_name in values:
|
||||
val = values[var_name]
|
||||
# Don't log sensitive values
|
||||
var_def = var_defs.get(var_name)
|
||||
if var_def and not var_def.sensitive:
|
||||
logger.debug("Substituting ${%s} = %s", var_name, val)
|
||||
else:
|
||||
logger.debug("Substituting ${%s} = [REDACTED]", var_name)
|
||||
return str(val)
|
||||
logger.warning("Unresolved variable: ${%s}", var_name)
|
||||
return match.group(0)
|
||||
|
||||
return re.sub(r"\$\{(\w+)\}", replacer, content)
|
||||
|
||||
def _compute_provenance(self, template_dir: Path) -> dict[str, Any]:
|
||||
"""Compute git commit hash and repo origin for template provenance."""
|
||||
provenance: dict[str, Any] = {"template_dir": str(template_dir)}
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["git", "rev-parse", "HEAD"],
|
||||
cwd=template_dir, capture_output=True, text=True, timeout=5,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
provenance["git_commit"] = result.stdout.strip()
|
||||
|
||||
result = subprocess.run(
|
||||
["git", "remote", "get-url", "origin"],
|
||||
cwd=template_dir, capture_output=True, text=True, timeout=5,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
provenance["git_origin"] = result.stdout.strip()
|
||||
except Exception:
|
||||
pass
|
||||
return provenance
|
||||
98
gsap_broker/templates/manifest.py
Normal file
98
gsap_broker/templates/manifest.py
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
# Copyright 2026 Guildhouse Dev
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
"""Bastion template manifest schema.
|
||||
|
||||
A template is a Git repository with a bastion.toml at the root.
|
||||
It contains policies, accords, harnesses, scripts, HBOM profiles,
|
||||
and optionally framework-specific automation (Ansible, Terraform, etc).
|
||||
|
||||
The manifest declares:
|
||||
- What the template provides (contents)
|
||||
- What it depends on (dependencies)
|
||||
- What the deployer must customize (variables)
|
||||
- What Bastion version and connectors it requires (compatibility)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import tomllib
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class TemplateMetadata(BaseModel):
|
||||
"""Template identification and categorization."""
|
||||
name: str
|
||||
version: str
|
||||
description: str = ""
|
||||
authors: list[str] = []
|
||||
license: str = "Apache-2.0"
|
||||
repository: Optional[str] = None
|
||||
|
||||
vertical: Optional[str] = None
|
||||
sub_vertical: Optional[str] = None
|
||||
compliance_frameworks: list[str] = []
|
||||
target_fleet_size: Optional[str] = None
|
||||
requires_vdi: bool = False
|
||||
requires_tpm: bool = False
|
||||
|
||||
|
||||
class TemplateCompatibility(BaseModel):
|
||||
"""What Bastion version and connectors this template needs."""
|
||||
bastion_min: str = "0.5.0"
|
||||
connectors_required: list[str] = []
|
||||
connectors_optional: list[str] = []
|
||||
|
||||
|
||||
class TemplateDependency(BaseModel):
|
||||
"""A dependency on another template repository."""
|
||||
git: str
|
||||
tag: Optional[str] = None
|
||||
branch: Optional[str] = None
|
||||
|
||||
|
||||
class TemplateVariable(BaseModel):
|
||||
"""A variable the deployer must or may customize."""
|
||||
type: str = "string"
|
||||
required: bool = False
|
||||
default: Optional[str] = None
|
||||
description: str = ""
|
||||
sensitive: bool = False
|
||||
|
||||
|
||||
class TemplateContents(BaseModel):
|
||||
"""Directory mapping for template content."""
|
||||
policies: str = "policies/"
|
||||
accords: str = "accords/"
|
||||
harnesses: str = "harnesses/"
|
||||
scripts: str = "scripts/"
|
||||
hbom_profiles: str = "hbom/"
|
||||
dashboards: str = "dashboards/"
|
||||
|
||||
|
||||
class TemplateManifest(BaseModel):
|
||||
"""Root schema for bastion.toml."""
|
||||
template: TemplateMetadata
|
||||
compatibility: TemplateCompatibility = TemplateCompatibility()
|
||||
dependencies: dict[str, TemplateDependency] = {}
|
||||
variables: dict[str, TemplateVariable] = {}
|
||||
contents: TemplateContents = TemplateContents()
|
||||
|
||||
@classmethod
|
||||
def from_toml(cls, path: str | Path) -> TemplateManifest:
|
||||
"""Parse a bastion.toml file."""
|
||||
with open(path, "rb") as f:
|
||||
data = tomllib.load(f)
|
||||
return cls.model_validate(data)
|
||||
|
||||
def validate_variables(self, provided: dict[str, Any]) -> list[str]:
|
||||
"""Check that all required variables are provided.
|
||||
Returns list of error messages (empty = valid)."""
|
||||
errors = []
|
||||
for name, var in self.variables.items():
|
||||
if var.required and name not in provided and var.default is None:
|
||||
errors.append(f"Required variable '{name}' not provided")
|
||||
return errors
|
||||
88
gsap_broker/templates/policy.py
Normal file
88
gsap_broker/templates/policy.py
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
# Copyright 2026 Guildhouse Dev
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
"""Declarative compliance policy schema.
|
||||
|
||||
A policy defines conditions that managed devices must satisfy.
|
||||
Each condition maps to a PostureConditionKind evaluated by
|
||||
Bastion's compliance engine. Conditions can have platform-specific
|
||||
check implementations (Intune field, script, Keylime attestation).
|
||||
|
||||
Policies are framework-agnostic. They describe WHAT must be true,
|
||||
not HOW to achieve it. Playbooks/scripts achieve compliance;
|
||||
policies evaluate it.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import tomllib
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class PlatformCheck(BaseModel):
|
||||
"""Platform-specific check implementation."""
|
||||
intune_field: Optional[str] = None
|
||||
intune_policy: Optional[str] = None
|
||||
expect: Optional[Any] = None
|
||||
script: Optional[str] = None
|
||||
keylime_check: Optional[str] = None
|
||||
|
||||
|
||||
class PolicyCondition(BaseModel):
|
||||
"""A single compliance condition."""
|
||||
id: str
|
||||
kind: str
|
||||
description: str = ""
|
||||
framework_ref: Optional[str] = None
|
||||
severity: str = "medium"
|
||||
optional: bool = False
|
||||
|
||||
linux: Optional[PlatformCheck] = None
|
||||
windows: Optional[PlatformCheck] = None
|
||||
macos: Optional[PlatformCheck] = None
|
||||
|
||||
|
||||
class BreachResponseConfig(BaseModel):
|
||||
"""What happens when conditions at each severity level fail."""
|
||||
critical: str = "suspend_access"
|
||||
high: str = "alert_msp"
|
||||
medium: str = "log_only"
|
||||
low: str = "log_only"
|
||||
|
||||
|
||||
class EvaluationSchedule(BaseModel):
|
||||
"""How often to evaluate compliance."""
|
||||
interval_seconds: int = 300
|
||||
full_evaluation_hours: int = 24
|
||||
|
||||
|
||||
class CompliancePolicy(BaseModel):
|
||||
"""A complete compliance policy definition."""
|
||||
name: str
|
||||
description: str = ""
|
||||
version: str = "1.0.0"
|
||||
framework: Optional[str] = None
|
||||
framework_controls: list[str] = []
|
||||
|
||||
conditions: list[PolicyCondition] = []
|
||||
breach_response: BreachResponseConfig = BreachResponseConfig()
|
||||
schedule: EvaluationSchedule = EvaluationSchedule()
|
||||
|
||||
@classmethod
|
||||
def from_toml(cls, path: str | Path) -> CompliancePolicy:
|
||||
"""Parse a policy TOML file."""
|
||||
with open(path, "rb") as f:
|
||||
data = tomllib.load(f)
|
||||
return cls.model_validate(data)
|
||||
|
||||
def conditions_for_platform(self, platform: str) -> list[PolicyCondition]:
|
||||
"""Return conditions applicable to a specific platform."""
|
||||
result = []
|
||||
for c in self.conditions:
|
||||
check = getattr(c, platform, None)
|
||||
if check is not None:
|
||||
result.append(c)
|
||||
return result
|
||||
112
gsap_broker/templates/registry.py
Normal file
112
gsap_broker/templates/registry.py
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
# Copyright 2026 Guildhouse Dev
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
"""Registries for loaded template content.
|
||||
|
||||
Policies, accords, and harnesses are loaded from templates
|
||||
and stored in memory. The broker's authorization flow and
|
||||
compliance evaluator query these registries.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any, Optional
|
||||
|
||||
from gsap_broker.templates.policy import CompliancePolicy
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class PolicyRegistry:
|
||||
"""Stores loaded compliance policies."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._policies: dict[str, tuple[CompliancePolicy, dict]] = {}
|
||||
|
||||
def register(self, policy: CompliancePolicy, provenance: dict | None = None) -> None:
|
||||
self._policies[policy.name] = (policy, provenance or {})
|
||||
logger.info("Policy registered: %s v%s", policy.name, policy.version)
|
||||
|
||||
def get(self, name: str) -> Optional[CompliancePolicy]:
|
||||
entry = self._policies.get(name)
|
||||
return entry[0] if entry else None
|
||||
|
||||
def list(self) -> list[CompliancePolicy]:
|
||||
return [p for p, _ in self._policies.values()]
|
||||
|
||||
def for_framework(self, framework: str) -> list[CompliancePolicy]:
|
||||
return [p for p, _ in self._policies.values() if p.framework == framework]
|
||||
|
||||
|
||||
class AccordRegistry:
|
||||
"""Stores loaded accord templates.
|
||||
|
||||
Replaces the hardcoded dict in mcp.py and authorize.py.
|
||||
Falls back to built-in defaults for known accords if no
|
||||
template has been loaded.
|
||||
"""
|
||||
|
||||
# Built-in defaults (from the original hardcoded dicts)
|
||||
_BUILTINS: dict[str, dict[str, Any]] = {
|
||||
"shell-exec": {
|
||||
"name": "shell-exec",
|
||||
"capability_ceiling": "CAP_MUTATE",
|
||||
"session_ttl_minutes": 30,
|
||||
"mfa_required": False,
|
||||
"device_compliance_required": False,
|
||||
},
|
||||
"dev-operations": {
|
||||
"name": "dev-operations",
|
||||
"capability_ceiling": "CAP_MUTATE",
|
||||
"session_ttl_minutes": 60,
|
||||
"mfa_required": False,
|
||||
"device_compliance_required": False,
|
||||
},
|
||||
"network-mutate": {
|
||||
"name": "network-mutate",
|
||||
"capability_ceiling": "CAP_GOVERN",
|
||||
"session_ttl_minutes": 15,
|
||||
"mfa_required": True,
|
||||
"ceremony_gate": "network-admin-elevated",
|
||||
"device_compliance_required": False,
|
||||
},
|
||||
"ai-delegation-standard": {
|
||||
"name": "ai-delegation-standard",
|
||||
"capability_ceiling": "CAP_MUTATE",
|
||||
"session_ttl_minutes": 60,
|
||||
"ceremony_required_for": ["delete", "destroy", "drop"],
|
||||
"max_commands": 500,
|
||||
"device_compliance_required": False,
|
||||
},
|
||||
"infrastructure-operations": {
|
||||
"name": "infrastructure-operations",
|
||||
"capability_ceiling": "CAP_MUTATE",
|
||||
"session_ttl_minutes": 30,
|
||||
"device_compliance_required": True,
|
||||
},
|
||||
"device-management": {
|
||||
"name": "device-management",
|
||||
"capability_ceiling": "CAP_MUTATE",
|
||||
"session_ttl_minutes": 30,
|
||||
"device_compliance_required": True,
|
||||
},
|
||||
}
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._accords: dict[str, tuple[dict, dict]] = {}
|
||||
|
||||
def register(self, name: str, accord: dict, provenance: dict | None = None) -> None:
|
||||
self._accords[name] = (accord, provenance or {})
|
||||
logger.info("Accord registered: %s", name)
|
||||
|
||||
def get(self, name: str) -> Optional[dict]:
|
||||
entry = self._accords.get(name)
|
||||
if entry:
|
||||
return entry[0]
|
||||
return self._BUILTINS.get(name)
|
||||
|
||||
def list(self) -> list[dict]:
|
||||
all_accords = dict(self._BUILTINS)
|
||||
all_accords.update({k: v for k, (v, _) in self._accords.items()})
|
||||
return list(all_accords.values())
|
||||
6
tests/fixtures/sample-template/accords/standard-operations.toml
vendored
Normal file
6
tests/fixtures/sample-template/accords/standard-operations.toml
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
name = "standard-operations"
|
||||
capability_ceiling = "CAP_MUTATE"
|
||||
session_ttl_minutes = 30
|
||||
mfa_required = false
|
||||
device_compliance_required = false
|
||||
description = "Standard operations accord for ${org_name}"
|
||||
8
tests/fixtures/sample-template/ansible/playbooks/test-playbook.yml
vendored
Normal file
8
tests/fixtures/sample-template/ansible/playbooks/test-playbook.yml
vendored
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
# This is a plain Ansible playbook. Bastion variable substitution
|
||||
# MUST NOT modify this file. ${org_name} should remain as-is.
|
||||
---
|
||||
- name: Test playbook for ${org_name}
|
||||
hosts: all
|
||||
tasks:
|
||||
- name: Ping
|
||||
ansible.builtin.ping:
|
||||
29
tests/fixtures/sample-template/bastion.toml
vendored
Normal file
29
tests/fixtures/sample-template/bastion.toml
vendored
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
[template]
|
||||
name = "test-baseline"
|
||||
version = "0.1.0"
|
||||
description = "Test template for Bastion loader tests"
|
||||
authors = ["Test Author"]
|
||||
vertical = "testing"
|
||||
compliance_frameworks = ["test-framework"]
|
||||
|
||||
[compatibility]
|
||||
bastion_min = "0.3.0"
|
||||
connectors_required = ["intune"]
|
||||
|
||||
[variables.org_name]
|
||||
type = "string"
|
||||
required = true
|
||||
description = "Organization name"
|
||||
|
||||
[variables.admin_email]
|
||||
type = "string"
|
||||
required = false
|
||||
default = "admin@example.com"
|
||||
description = "Admin email"
|
||||
|
||||
[variables.api_key]
|
||||
type = "string"
|
||||
required = false
|
||||
default = "test-key"
|
||||
description = "API key"
|
||||
sensitive = true
|
||||
37
tests/fixtures/sample-template/policies/test-workstation.toml
vendored
Normal file
37
tests/fixtures/sample-template/policies/test-workstation.toml
vendored
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
name = "test-workstation-policy"
|
||||
description = "Test workstation compliance for ${org_name}"
|
||||
version = "1.0.0"
|
||||
framework = "test-framework"
|
||||
framework_controls = ["TC-001", "TC-002"]
|
||||
|
||||
[[conditions]]
|
||||
id = "disk-encryption"
|
||||
kind = "DiskEncryption"
|
||||
description = "Full disk encryption required"
|
||||
framework_ref = "TC-001"
|
||||
severity = "critical"
|
||||
|
||||
[conditions.linux]
|
||||
script = "scripts/linux/check-encryption.sh"
|
||||
expect = "encrypted"
|
||||
|
||||
[conditions.windows]
|
||||
intune_field = "isEncrypted"
|
||||
expect = true
|
||||
|
||||
[[conditions]]
|
||||
id = "antivirus-active"
|
||||
kind = "AntivirusActive"
|
||||
description = "Antivirus must be running"
|
||||
severity = "high"
|
||||
|
||||
[conditions.windows]
|
||||
intune_field = "antiVirusStatus"
|
||||
expect = "active"
|
||||
|
||||
[breach_response]
|
||||
critical = "suspend_access"
|
||||
high = "alert_msp"
|
||||
|
||||
[schedule]
|
||||
interval_seconds = 300
|
||||
180
tests/test_templates.py
Normal file
180
tests/test_templates.py
Normal file
|
|
@ -0,0 +1,180 @@
|
|||
# Copyright 2026 Guildhouse Dev
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
"""Tests for the template system — manifest, policy, loader, registry."""
|
||||
|
||||
import os
|
||||
import pytest
|
||||
from pathlib import Path
|
||||
|
||||
from gsap_broker.templates.manifest import TemplateManifest
|
||||
from gsap_broker.templates.policy import CompliancePolicy
|
||||
from gsap_broker.templates.loader import TemplateLoader, LoadResult
|
||||
from gsap_broker.templates.registry import AccordRegistry, PolicyRegistry
|
||||
|
||||
FIXTURE_DIR = Path(__file__).parent / "fixtures" / "sample-template"
|
||||
|
||||
|
||||
# ── Manifest parsing ─────────────────────────────────────────────
|
||||
|
||||
def test_parse_full_manifest():
|
||||
"""TEST 2: Full bastion.toml parsed correctly."""
|
||||
m = TemplateManifest.from_toml(FIXTURE_DIR / "bastion.toml")
|
||||
assert m.template.name == "test-baseline"
|
||||
assert m.template.version == "0.1.0"
|
||||
assert m.template.vertical == "testing"
|
||||
assert "test-framework" in m.template.compliance_frameworks
|
||||
assert m.compatibility.bastion_min == "0.3.0"
|
||||
assert "intune" in m.compatibility.connectors_required
|
||||
assert "org_name" in m.variables
|
||||
assert m.variables["org_name"].required is True
|
||||
assert m.variables["api_key"].sensitive is True
|
||||
|
||||
|
||||
def test_parse_minimal_manifest(tmp_path):
|
||||
"""TEST 3: Minimal bastion.toml with only required fields."""
|
||||
toml = tmp_path / "bastion.toml"
|
||||
toml.write_text('[template]\nname = "minimal"\nversion = "0.1.0"\n')
|
||||
m = TemplateManifest.from_toml(toml)
|
||||
assert m.template.name == "minimal"
|
||||
assert m.compatibility.bastion_min == "0.5.0" # default
|
||||
assert m.contents.policies == "policies/" # default
|
||||
|
||||
|
||||
def test_validate_variables_missing_required():
|
||||
"""TEST 4: Missing required variable produces error."""
|
||||
m = TemplateManifest.from_toml(FIXTURE_DIR / "bastion.toml")
|
||||
errors = m.validate_variables({}) # org_name missing
|
||||
assert any("org_name" in e for e in errors)
|
||||
|
||||
|
||||
def test_validate_variables_all_provided():
|
||||
"""TEST 5: All required variables provided — no errors."""
|
||||
m = TemplateManifest.from_toml(FIXTURE_DIR / "bastion.toml")
|
||||
errors = m.validate_variables({"org_name": "TestCorp"})
|
||||
assert errors == []
|
||||
|
||||
|
||||
# ── Policy parsing ───────────────────────────────────────────────
|
||||
|
||||
def test_parse_policy():
|
||||
"""TEST 6: Multi-condition policy parsed correctly."""
|
||||
p = CompliancePolicy.from_toml(FIXTURE_DIR / "policies" / "test-workstation.toml")
|
||||
assert p.name == "test-workstation-policy"
|
||||
assert p.framework == "test-framework"
|
||||
assert len(p.conditions) == 2
|
||||
assert p.conditions[0].id == "disk-encryption"
|
||||
assert p.conditions[0].severity == "critical"
|
||||
assert p.breach_response.critical == "suspend_access"
|
||||
assert p.schedule.interval_seconds == 300
|
||||
|
||||
|
||||
def test_policy_platform_filtering():
|
||||
"""TEST 7: conditions_for_platform filters correctly."""
|
||||
p = CompliancePolicy.from_toml(FIXTURE_DIR / "policies" / "test-workstation.toml")
|
||||
linux_conds = p.conditions_for_platform("linux")
|
||||
windows_conds = p.conditions_for_platform("windows")
|
||||
|
||||
# disk-encryption has linux check, antivirus doesn't
|
||||
assert len(linux_conds) == 1
|
||||
assert linux_conds[0].id == "disk-encryption"
|
||||
|
||||
# Both conditions have windows checks
|
||||
assert len(windows_conds) == 2
|
||||
|
||||
|
||||
# ── Template loader ──────────────────────────────────────────────
|
||||
|
||||
def test_template_loader_full():
|
||||
"""TEST 8: Full template load — policies and accords registered."""
|
||||
policies = PolicyRegistry()
|
||||
accords = AccordRegistry()
|
||||
loader = TemplateLoader(policies, accords)
|
||||
|
||||
result = loader.load(FIXTURE_DIR, {"org_name": "TestCorp"})
|
||||
|
||||
assert result.success
|
||||
assert "test-workstation-policy" in result.policies_loaded
|
||||
assert "standard-operations" in result.accords_loaded
|
||||
assert policies.get("test-workstation-policy") is not None
|
||||
assert accords.get("standard-operations") is not None
|
||||
|
||||
|
||||
def test_variable_substitution_bastion_files_only():
|
||||
"""TEST 9: Variable substitution applies to Bastion files, not Ansible files."""
|
||||
policies = PolicyRegistry()
|
||||
accords = AccordRegistry()
|
||||
loader = TemplateLoader(policies, accords)
|
||||
|
||||
loader.load(FIXTURE_DIR, {"org_name": "AcmeCorp"})
|
||||
|
||||
# Policy file should have substitution applied
|
||||
policy = policies.get("test-workstation-policy")
|
||||
assert policy is not None
|
||||
assert "AcmeCorp" in policy.description
|
||||
|
||||
# Ansible playbook should be UNTOUCHED
|
||||
playbook_path = FIXTURE_DIR / "ansible" / "playbooks" / "test-playbook.yml"
|
||||
content = playbook_path.read_text()
|
||||
assert "${org_name}" in content # NOT substituted
|
||||
|
||||
|
||||
def test_variable_substitution_sensitive_not_logged(caplog):
|
||||
"""TEST 10: Sensitive variable values not in log output."""
|
||||
import logging
|
||||
policies = PolicyRegistry()
|
||||
accords = AccordRegistry()
|
||||
loader = TemplateLoader(policies, accords)
|
||||
|
||||
with caplog.at_level(logging.DEBUG):
|
||||
loader.load(FIXTURE_DIR, {"org_name": "TestCorp", "api_key": "SECRET_VALUE_123"})
|
||||
|
||||
# The sensitive value should not appear in logs
|
||||
assert "SECRET_VALUE_123" not in caplog.text
|
||||
# Non-sensitive org_name value DOES appear (confirms substitution ran)
|
||||
assert "TestCorp" in caplog.text
|
||||
|
||||
|
||||
def test_missing_bastion_toml(tmp_path):
|
||||
"""Missing bastion.toml produces error, not crash."""
|
||||
policies = PolicyRegistry()
|
||||
accords = AccordRegistry()
|
||||
loader = TemplateLoader(policies, accords)
|
||||
|
||||
result = loader.load(tmp_path, {})
|
||||
assert not result.success
|
||||
assert any("bastion.toml" in e for e in result.errors)
|
||||
|
||||
|
||||
# ── Registries ───────────────────────────────────────────────────
|
||||
|
||||
def test_accord_registry_builtin_fallback():
|
||||
"""TEST 11: AccordRegistry falls back to builtins for known accords."""
|
||||
reg = AccordRegistry()
|
||||
# shell-exec is a builtin
|
||||
assert reg.get("shell-exec") is not None
|
||||
assert reg.get("shell-exec")["capability_ceiling"] == "CAP_MUTATE"
|
||||
# unknown returns None
|
||||
assert reg.get("nonexistent") is None
|
||||
|
||||
|
||||
def test_accord_registry_template_overrides_builtin():
|
||||
"""Template-loaded accord overrides builtin with same name."""
|
||||
reg = AccordRegistry()
|
||||
reg.register("shell-exec", {"name": "shell-exec", "custom": True})
|
||||
assert reg.get("shell-exec")["custom"] is True
|
||||
|
||||
|
||||
def test_policy_registry_by_framework():
|
||||
"""PolicyRegistry.for_framework filters correctly."""
|
||||
reg = PolicyRegistry()
|
||||
p1 = CompliancePolicy(name="p1", framework="hipaa")
|
||||
p2 = CompliancePolicy(name="p2", framework="pci")
|
||||
p3 = CompliancePolicy(name="p3", framework="hipaa")
|
||||
reg.register(p1)
|
||||
reg.register(p2)
|
||||
reg.register(p3)
|
||||
|
||||
hipaa = reg.for_framework("hipaa")
|
||||
assert len(hipaa) == 2
|
||||
assert all(p.framework == "hipaa" for p in hipaa)
|
||||
Loading…
Reference in a new issue