#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>
87 lines
3.8 KiB
Python
87 lines
3.8 KiB
Python
"""What a playbook run may be pointed at, and which variables only an admin
|
|
may set.
|
|
|
|
Ansible runs inside the Steward container, so a run is code on the Steward
|
|
server unless it is held to playbooks its source lists and to variables that
|
|
only shape what happens on the target. An operator may run playbooks and set
|
|
their variables. Three things would let one run commands on Steward itself,
|
|
and are refused for anyone but an admin:
|
|
|
|
- a playbook or inventory path outside what the source lists;
|
|
- an `ansible_*` variable that changes how or where Ansible connects
|
|
(`ansible_connection: local`, `ansible_ssh_common_args` with a
|
|
ProxyCommand, an interpreter or executable path);
|
|
- a Jinja expression in any value (`{{ lookup('pipe', …) }}` is evaluated
|
|
on the controller when the value is used).
|
|
|
|
Playbook files themselves are admin-only to write (ansible/routes.py).
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from steward.ansible import sources as src_module
|
|
from steward.models.users import UserRole
|
|
|
|
# The ansible_* variables an operator may set: which address, port and user
|
|
# to reach a target as, and whether to become another user there.
|
|
OPERATOR_ANSIBLE_VARS = frozenset({
|
|
"ansible_host", "ansible_port", "ansible_user", "ansible_password",
|
|
"ansible_become", "ansible_become_user", "ansible_become_password",
|
|
})
|
|
_JINJA = ("{{", "{%", "{#")
|
|
|
|
|
|
def check_playbook(source: dict, playbook_path: str) -> str | None:
|
|
"""None when the source lists this playbook, else why it is refused."""
|
|
if playbook_path not in src_module.discover_playbooks(source["path"]):
|
|
return f"Source {source['name']!r} has no playbook {playbook_path!r}"
|
|
return None
|
|
|
|
|
|
def check_inventory_scope(source: dict, scope: str) -> str | None:
|
|
"""None for a Steward scope or an inventory file the source has, else why not."""
|
|
if scope.startswith("steward:"):
|
|
return None
|
|
if scope.startswith("repo:"):
|
|
parts = scope.split(":", 2)
|
|
path = parts[2] if len(parts) == 3 else ""
|
|
if path in src_module.discover_inventories(source["path"]):
|
|
return None
|
|
return f"Source {source['name']!r} has no inventory file {path!r}"
|
|
return "Invalid inventory_scope"
|
|
|
|
|
|
def _has_jinja(value) -> bool:
|
|
if isinstance(value, str):
|
|
return any(mark in value for mark in _JINJA)
|
|
if isinstance(value, dict):
|
|
return any(_has_jinja(k) or _has_jinja(v) for k, v in value.items())
|
|
if isinstance(value, (list, tuple)):
|
|
return any(_has_jinja(v) for v in value)
|
|
return False
|
|
|
|
|
|
def check_vars_for_role(variables: dict | None, role: UserRole | None) -> str | None:
|
|
"""None when `role` may set these variables, else a message naming what
|
|
only an admin may set. role=None is treated as the least privileged."""
|
|
if role == UserRole.admin or not variables:
|
|
return None
|
|
names = sorted(n for n in variables
|
|
if str(n).startswith("ansible_") and n not in OPERATOR_ANSIBLE_VARS)
|
|
if names:
|
|
return (f"Only an admin can set {', '.join(names)}: these change how or where "
|
|
"Ansible connects, and can run commands on the Steward server itself.")
|
|
templated = sorted(str(k) for k, v in variables.items() if _has_jinja(k) or _has_jinja(v))
|
|
if templated:
|
|
return (f"Only an admin can set a value containing {{{{ }}}}, {{% %}} or {{# #}} "
|
|
f"({', '.join(templated)}): Ansible evaluates it on the Steward server.")
|
|
return None
|
|
|
|
|
|
|
|
def changed_vars(new: dict | None, old: dict | None) -> dict:
|
|
"""The entries of `new` that are added or differ from `old`. An edit is
|
|
held to the editor's role only for what it changes, so an operator can
|
|
still edit a group whose connection vars an admin set."""
|
|
old = old or {}
|
|
return {k: v for k, v in (new or {}).items() if k not in old or old[k] != v}
|