feat(ansible): runs stay inside their source; controller-side vars are admin-only (#5034)
#5034 "Playbook runs: path and inventory checks in trigger_run, connection vars admin-only, confirm enforced server-side, web actions audited". Ansible runs inside the Steward container, so an operator who could name any playbook path, set ansible_connection=local or ProxyCommand ssh args, or put {{ lookup('pipe', …) }} in a value could run commands on Steward. - steward/ansible/guards.py: check_playbook (the source must list it), check_inventory_scope (steward:* or an inventory file the source has), check_vars_for_role (ansible_* beyond host/port/user/password/become, and any Jinja in a value, are admin-only), changed_vars (an edit is held to the role only for what it changes). - trigger_run applies the path checks, so every run path — web, MCP, alerts, schedules, plugins — gets them. - Variables are checked by role on the web run form, host-page runs, schedule create/update, inventory group/target create/update, and MCP run_playbook / create_schedule / update_schedule. - A steward:confirm playbook needs the confirmation ticked on the server side for a real (non-check) web run, as MCP already required. - Audited: runs, cancel, schedule create/update/enable/disable/delete/ run-now, playbook create/update/delete, inventory group and target create/update/delete (variable names only, never values). One audit_request helper serves the web routes. - The CSRF form guard and the route guard now each test that they fail on a synthetic broken input (rule: a guard must be able to fail). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -10,7 +10,7 @@ import asyncio
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from steward.ansible import executor, sources as src_module
|
||||
from steward.ansible import executor, guards, sources as src_module
|
||||
from steward.ansible.inventory_gen import (
|
||||
fetch_scope_targets, generate_inventory, inventory_to_yaml,
|
||||
)
|
||||
@@ -47,6 +47,13 @@ async def trigger_run(
|
||||
source = next((s for s in sources if s["name"] == source_name), None)
|
||||
if source is None:
|
||||
return None, None, "Source not found"
|
||||
# Every run path (web, MCP, alerts, schedules, plugins) arrives here, so
|
||||
# this is where a run is held to what its source lists (ansible/guards.py).
|
||||
err = guards.check_playbook(source, playbook_path)
|
||||
if err is None and inventory_content is None:
|
||||
err = guards.check_inventory_scope(source, inventory_scope)
|
||||
if err:
|
||||
return None, source, err
|
||||
|
||||
inventory_path: str | None = None
|
||||
if inventory_content is not None:
|
||||
@@ -55,11 +62,8 @@ async def trigger_run(
|
||||
async with app.db_sessionmaker() as db:
|
||||
targets = await fetch_scope_targets(db, inventory_scope)
|
||||
inventory_content = inventory_to_yaml(generate_inventory(targets))
|
||||
elif inventory_scope.startswith("repo:"):
|
||||
parts = inventory_scope.split(":", 2)
|
||||
inventory_path = parts[2] if len(parts) == 3 else ""
|
||||
else:
|
||||
return None, source, "Invalid inventory_scope"
|
||||
inventory_path = inventory_scope.split(":", 2)[2]
|
||||
|
||||
run_id = str(uuid.uuid4())
|
||||
run = AnsibleRun(
|
||||
|
||||
Reference in New Issue
Block a user