feat(ansible): runs stay inside their source; controller-side vars are admin-only (#5034)
CI / lint (push) Successful in 2s
CI / unit (push) Successful in 1m12s
CI / integration (push) Successful in 59s
CI / publish (push) Successful in 1m3s

#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:
2026-10-06 10:57:34 -04:00
co-authored by Claude Opus 5.5
parent 6a1bc437c9
commit 8dad79f17a
11 changed files with 393 additions and 42 deletions
+43 -5
View File
@@ -3,11 +3,13 @@ from __future__ import annotations
import uuid
import yaml
from quart import Blueprint, current_app, redirect, render_template, request, url_for
from quart import Blueprint, current_app, redirect, render_template, request, session, url_for
from sqlalchemy import select
from sqlalchemy.orm import selectinload
from steward.ansible import guards
from steward.auth.middleware import require_role
from steward.core.audit import audit_request as _audit
from steward.models.ansible_inventory import AnsibleGroup, AnsibleTarget
from steward.models.users import UserRole
@@ -30,6 +32,12 @@ def _parse_ansible_vars(raw: str) -> dict:
return parsed
def _vars_refusal(new: dict, old: dict | None = None) -> str | None:
"""Why this editor may not make this vars change, or None (ansible/guards.py)."""
return guards.check_vars_for_role(guards.changed_vars(new, old),
UserRole(session.get("user_role", "viewer")))
# ---------------------------------------------------------------------------
# Groups
# ---------------------------------------------------------------------------
@@ -59,10 +67,16 @@ async def groups_create():
except ValueError as exc:
return str(exc), 400
err = _vars_refusal(ansible_vars)
if err:
return err, 400
group = AnsibleGroup(id=str(uuid.uuid4()), name=name, ansible_vars=ansible_vars)
async with current_app.db_sessionmaker() as db:
async with db.begin():
db.add(group)
await _audit("ansible.group.created", "ansible_group", group.id,
{"name": name, "vars": sorted(ansible_vars)})
return redirect(url_for("ansible_inventory.groups_list"))
@@ -111,6 +125,9 @@ async def group_update(group_id: str):
group = result.scalar_one_or_none()
if group is None:
return "Group not found", 404
err = _vars_refusal(ansible_vars, group.ansible_vars)
if err:
return err, 400
name = form.get("name", group.name).strip()
if name:
@@ -123,7 +140,10 @@ async def group_update(group_id: str):
).scalars().all()
target_map = {t.id: t for t in all_targets}
group.targets = [target_map[tid] for tid in new_ids if tid in target_map]
detail = {"name": group.name, "vars": sorted(ansible_vars),
"targets": sorted(t.name for t in group.targets)}
await _audit("ansible.group.updated", "ansible_group", group_id, detail)
return redirect(url_for("ansible_inventory.group_detail", group_id=group_id))
@@ -136,8 +156,11 @@ async def group_delete(group_id: str):
select(AnsibleGroup).where(AnsibleGroup.id == group_id)
)
group = result.scalar_one_or_none()
if group is not None:
await db.delete(group)
if group is None:
return redirect(url_for("ansible_inventory.groups_list"))
name = group.name
await db.delete(group)
await _audit("ansible.group.deleted", "ansible_group", group_id, {"name": name})
return redirect(url_for("ansible_inventory.groups_list"))
@@ -171,12 +194,18 @@ async def targets_create():
except ValueError as exc:
return str(exc), 400
err = _vars_refusal(ansible_vars)
if err:
return err, 400
target = AnsibleTarget(
id=str(uuid.uuid4()), name=name, address=address, ansible_vars=ansible_vars
)
async with current_app.db_sessionmaker() as db:
async with db.begin():
db.add(target)
await _audit("ansible.target.created", "ansible_target", target.id,
{"name": name, "address": address, "vars": sorted(ansible_vars)})
return redirect(url_for("ansible_inventory.targets_list"))
@@ -225,6 +254,9 @@ async def target_update(target_id: str):
target = result.scalar_one_or_none()
if target is None:
return "Target not found", 404
err = _vars_refusal(ansible_vars, target.ansible_vars)
if err:
return err, 400
name = form.get("name", target.name).strip()
address = form.get("address", target.address).strip()
@@ -238,7 +270,10 @@ async def target_update(target_id: str):
all_groups_result = await db.execute(select(AnsibleGroup))
group_map = {g.id: g for g in all_groups_result.scalars().all()}
target.groups = [group_map[gid] for gid in new_group_ids if gid in group_map]
detail = {"name": target.name, "address": target.address, "vars": sorted(ansible_vars),
"groups": sorted(g.name for g in target.groups)}
await _audit("ansible.target.updated", "ansible_target", target_id, detail)
return redirect(url_for("ansible_inventory.target_detail", target_id=target_id))
@@ -251,6 +286,9 @@ async def target_delete(target_id: str):
select(AnsibleTarget).where(AnsibleTarget.id == target_id)
)
target = result.scalar_one_or_none()
if target is not None:
await db.delete(target)
if target is None:
return redirect(url_for("ansible_inventory.targets_list"))
name = target.name
await db.delete(target)
await _audit("ansible.target.deleted", "ansible_target", target_id, {"name": name})
return redirect(url_for("ansible_inventory.targets_list"))