Files
FabledSteward/steward/ansible/guards.py
T
bvandeusenandClaude Opus 5.5 8dad79f17a
CI / lint (push) Successful in 2s
CI / unit (push) Successful in 1m12s
CI / integration (push) Successful in 59s
CI / publish (push) Successful in 1m3s
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>
2026-10-06 10:57:34 -04:00

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}