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:
@@ -8,9 +8,10 @@ from quart import (
|
||||
)
|
||||
from sqlalchemy import select
|
||||
|
||||
from steward.ansible import executor, sources as src_module
|
||||
from steward.ansible import executor, guards, sources as src_module
|
||||
from steward.ansible.inventory_gen import scope_labels
|
||||
from steward.auth.middleware import require_role
|
||||
from steward.core.audit import audit_request as _audit
|
||||
from steward.models.ansible import AnsibleRun, AnsibleRunStatus
|
||||
from steward.models.ansible_schedule import AnsibleSchedule
|
||||
from steward.models.users import User, UserRole
|
||||
@@ -105,6 +106,30 @@ async def view_playbook(source_name: str, playbook_path: str):
|
||||
)
|
||||
|
||||
|
||||
def _caller_role() -> UserRole:
|
||||
return UserRole(session.get("user_role", "viewer"))
|
||||
|
||||
|
||||
def _run_vars(params: dict | None, secret_vars: dict | None = None) -> dict:
|
||||
return {**((params or {}).get("extra_vars_map") or {}), **(secret_vars or {})}
|
||||
|
||||
|
||||
def check_run_form(form, source: dict | None, playbook_path: str,
|
||||
params: dict | None, secret_vars: dict | None) -> str | None:
|
||||
"""The server-side gates a web run must pass (the form's own checks are
|
||||
only a convenience): who may set these variables, and the playbook's
|
||||
steward:confirm flag, which a real (non-check) run must have ticked."""
|
||||
err = guards.check_vars_for_role(_run_vars(params, secret_vars), _caller_role())
|
||||
if err:
|
||||
return err
|
||||
content = src_module.read_playbook(source["path"], playbook_path) if source else None
|
||||
meta = src_module.discover_playbook_meta(content or "")
|
||||
if meta["confirm"] and not (params or {}).get("check") and "confirmed" not in form:
|
||||
return (f"{playbook_path} is flagged destructive by its author (steward:confirm). "
|
||||
"Tick the confirmation to run it, or run it in check mode.")
|
||||
return None
|
||||
|
||||
|
||||
def _parse_run_params(form) -> tuple[dict | None, dict | None, str | None]:
|
||||
"""Build executor params + the unpersisted secret-vars dict from form fields.
|
||||
|
||||
@@ -235,6 +260,10 @@ async def create_run():
|
||||
return "playbook_path is required", 400
|
||||
|
||||
params_or_none, secret_vars, err = _parse_run_params(form)
|
||||
if err:
|
||||
return err, 400
|
||||
source = next((s for s in _get_sources() if s["name"] == source_name), None)
|
||||
err = check_run_form(form, source, playbook_path, params_or_none, secret_vars)
|
||||
if err:
|
||||
return err, 400
|
||||
|
||||
@@ -251,6 +280,10 @@ async def create_run():
|
||||
return err, 404
|
||||
if err:
|
||||
return err, 400
|
||||
await _audit("ansible.run.started", "ansible_run", run.id, {
|
||||
"source": source_name, "playbook": playbook_path, "scope": inventory_scope,
|
||||
"check": bool((params_or_none or {}).get("check")),
|
||||
"vars": sorted(_run_vars(params_or_none, secret_vars))})
|
||||
|
||||
return await render_template(
|
||||
"ansible/run_started.html",
|
||||
@@ -323,6 +356,8 @@ async def cancel_run(run_id: str):
|
||||
return "Not found", 404
|
||||
if run.status in (AnsibleRunStatus.running, AnsibleRunStatus.queued):
|
||||
executor.cancel_run(run_id)
|
||||
await _audit("ansible.run.cancelled", "ansible_run", run_id,
|
||||
{"playbook": run.playbook_path})
|
||||
return redirect(url_for("ansible.run_detail", run_id=run_id))
|
||||
|
||||
|
||||
@@ -441,6 +476,15 @@ def _schedule_form_fields(form) -> tuple[dict | None, str | None]:
|
||||
}, None
|
||||
|
||||
|
||||
def _schedule_detail(fields: dict) -> dict:
|
||||
"""What a schedule audit row records: what runs, where, how often — and
|
||||
which variables, by name only."""
|
||||
return {"name": fields["name"], "source": fields["source_name"],
|
||||
"playbook": fields["playbook_path"], "scope": fields["inventory_scope"],
|
||||
"interval_seconds": fields["interval_seconds"],
|
||||
"vars": sorted(_run_vars(fields["params"]))}
|
||||
|
||||
|
||||
@ansible_bp.post("/schedules")
|
||||
@require_role(UserRole.operator)
|
||||
async def create_schedule():
|
||||
@@ -448,9 +492,14 @@ async def create_schedule():
|
||||
fields, err = _schedule_form_fields(form)
|
||||
if err:
|
||||
return err, 400
|
||||
err = guards.check_vars_for_role(_run_vars(fields["params"]), _caller_role())
|
||||
if err:
|
||||
return err, 400
|
||||
sched = AnsibleSchedule(enabled=True, **fields)
|
||||
async with current_app.db_sessionmaker() as db:
|
||||
async with db.begin():
|
||||
db.add(AnsibleSchedule(enabled=True, **fields))
|
||||
db.add(sched)
|
||||
await _audit("ansible.schedule.created", "ansible_schedule", sched.id, _schedule_detail(fields))
|
||||
return redirect(url_for("ansible.schedules"))
|
||||
|
||||
|
||||
@@ -466,8 +515,14 @@ async def update_schedule(schedule_id: str):
|
||||
sched = await db.get(AnsibleSchedule, schedule_id)
|
||||
if sched is None:
|
||||
return "Not found", 404
|
||||
err = guards.check_vars_for_role(
|
||||
guards.changed_vars(_run_vars(fields["params"]), _run_vars(sched.params)),
|
||||
_caller_role())
|
||||
if err:
|
||||
return err, 400
|
||||
for k, v in fields.items():
|
||||
setattr(sched, k, v)
|
||||
await _audit("ansible.schedule.updated", "ansible_schedule", schedule_id, _schedule_detail(fields))
|
||||
return redirect(url_for("ansible.schedules"))
|
||||
|
||||
|
||||
@@ -480,6 +535,9 @@ async def toggle_schedule(schedule_id: str):
|
||||
if sched is None:
|
||||
return "Not found", 404
|
||||
sched.enabled = not sched.enabled
|
||||
enabled, name = sched.enabled, sched.name
|
||||
await _audit("ansible.schedule.enabled" if enabled else "ansible.schedule.disabled",
|
||||
"ansible_schedule", schedule_id, {"name": name})
|
||||
return redirect(url_for("ansible.schedules"))
|
||||
|
||||
|
||||
@@ -489,8 +547,11 @@ async def delete_schedule(schedule_id: str):
|
||||
async with current_app.db_sessionmaker() as db:
|
||||
async with db.begin():
|
||||
sched = await db.get(AnsibleSchedule, schedule_id)
|
||||
if sched is not None:
|
||||
await db.delete(sched)
|
||||
if sched is None:
|
||||
return redirect(url_for("ansible.schedules"))
|
||||
name = sched.name
|
||||
await db.delete(sched)
|
||||
await _audit("ansible.schedule.deleted", "ansible_schedule", schedule_id, {"name": name})
|
||||
return redirect(url_for("ansible.schedules"))
|
||||
|
||||
|
||||
@@ -519,6 +580,8 @@ async def run_schedule_now(schedule_id: str):
|
||||
fresh.last_run_at = datetime.now(timezone.utc)
|
||||
fresh.last_run_id = run.id if run else None
|
||||
fresh.last_error = err
|
||||
await _audit("ansible.schedule.run_now", "ansible_schedule", schedule_id,
|
||||
{"playbook": playbook_path, "run_id": run.id if run else None, "error": err})
|
||||
if run:
|
||||
return redirect(url_for("ansible.run_detail", run_id=run.id))
|
||||
return redirect(url_for("ansible.schedules"))
|
||||
@@ -595,6 +658,9 @@ async def save_playbook():
|
||||
content=content, error=err,
|
||||
), 400
|
||||
|
||||
await _audit("ansible.playbook.updated" if editing else "ansible.playbook.created",
|
||||
"ansible_playbook", f"{source_name}:{playbook_path}",
|
||||
{"source": source_name, "playbook": playbook_path, "bytes": len(content)})
|
||||
return redirect(url_for(
|
||||
"ansible.view_playbook", source_name=source_name, playbook_path=playbook_path))
|
||||
|
||||
@@ -606,9 +672,12 @@ async def delete_playbook():
|
||||
source = _editable_source((form.get("source_name", "") or "").strip())
|
||||
if source is None:
|
||||
return "Source is not editable", 400
|
||||
ok, err = src_module.delete_playbook(source["path"], (form.get("playbook_path", "") or "").strip())
|
||||
playbook_path = (form.get("playbook_path", "") or "").strip()
|
||||
ok, err = src_module.delete_playbook(source["path"], playbook_path)
|
||||
if not ok:
|
||||
return err, 400
|
||||
await _audit("ansible.playbook.deleted", "ansible_playbook", f"{source['name']}:{playbook_path}",
|
||||
{"source": source["name"], "playbook": playbook_path})
|
||||
return redirect(url_for("ansible.browse"))
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user