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
+24
View File
@@ -132,3 +132,27 @@ def test_builtin_playbooks_get_steward_url_from_steward():
finally:
_context._app_ctx.reset(a)
_context._base_url_ctx.reset(u)
async def test_run_playbook_holds_variables_to_the_callers_role(monkeypatch):
monkeypatch.setattr(automation, "_source", lambda name: {"name": name, "path": "/x"})
monkeypatch.setattr(automation, "_playbook", lambda s, p: "- hosts: all\n tasks: []\n")
def _no_run(*a, **k):
raise AssertionError("a run was started")
monkeypatch.setattr(automation, "trigger_run", _no_run)
operator = Caller(user_id="u", username="op", role=UserRole.operator, token_id=1)
monkeypatch.setattr(automation, "current_caller_or_none", lambda: operator)
with pytest.raises(ValueError, match="Only an admin can set ansible_connection"):
await automation.run_playbook("s", "site.yml", extra_vars={"ansible_connection": "local"})
with pytest.raises(ValueError, match="Ansible evaluates it"):
await automation.run_playbook("s", "site.yml", extra_vars={"x": "{{ lookup('pipe','id') }}"})
def test_schedule_update_holds_only_changed_variables(monkeypatch):
operator = Caller(user_id="u", username="op", role=UserRole.operator, token_id=1)
monkeypatch.setattr(automation, "current_caller_or_none", lambda: operator)
stored = {"ansible_ssh_common_args": "-J bastion"}
automation.check_caller_vars({**stored, "release": "2"}, stored)
with pytest.raises(ValueError, match="Only an admin"):
automation.check_caller_vars({"ansible_ssh_common_args": "-o ProxyCommand=x"}, stored)