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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user