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:
@@ -3,11 +3,13 @@ from __future__ import annotations
|
||||
import uuid
|
||||
import yaml
|
||||
|
||||
from quart import Blueprint, current_app, redirect, render_template, request, url_for
|
||||
from quart import Blueprint, current_app, redirect, render_template, request, session, url_for
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from steward.ansible import guards
|
||||
from steward.auth.middleware import require_role
|
||||
from steward.core.audit import audit_request as _audit
|
||||
from steward.models.ansible_inventory import AnsibleGroup, AnsibleTarget
|
||||
from steward.models.users import UserRole
|
||||
|
||||
@@ -30,6 +32,12 @@ def _parse_ansible_vars(raw: str) -> dict:
|
||||
return parsed
|
||||
|
||||
|
||||
def _vars_refusal(new: dict, old: dict | None = None) -> str | None:
|
||||
"""Why this editor may not make this vars change, or None (ansible/guards.py)."""
|
||||
return guards.check_vars_for_role(guards.changed_vars(new, old),
|
||||
UserRole(session.get("user_role", "viewer")))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Groups
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -59,10 +67,16 @@ async def groups_create():
|
||||
except ValueError as exc:
|
||||
return str(exc), 400
|
||||
|
||||
err = _vars_refusal(ansible_vars)
|
||||
if err:
|
||||
return err, 400
|
||||
|
||||
group = AnsibleGroup(id=str(uuid.uuid4()), name=name, ansible_vars=ansible_vars)
|
||||
async with current_app.db_sessionmaker() as db:
|
||||
async with db.begin():
|
||||
db.add(group)
|
||||
await _audit("ansible.group.created", "ansible_group", group.id,
|
||||
{"name": name, "vars": sorted(ansible_vars)})
|
||||
return redirect(url_for("ansible_inventory.groups_list"))
|
||||
|
||||
|
||||
@@ -111,6 +125,9 @@ async def group_update(group_id: str):
|
||||
group = result.scalar_one_or_none()
|
||||
if group is None:
|
||||
return "Group not found", 404
|
||||
err = _vars_refusal(ansible_vars, group.ansible_vars)
|
||||
if err:
|
||||
return err, 400
|
||||
|
||||
name = form.get("name", group.name).strip()
|
||||
if name:
|
||||
@@ -123,7 +140,10 @@ async def group_update(group_id: str):
|
||||
).scalars().all()
|
||||
target_map = {t.id: t for t in all_targets}
|
||||
group.targets = [target_map[tid] for tid in new_ids if tid in target_map]
|
||||
detail = {"name": group.name, "vars": sorted(ansible_vars),
|
||||
"targets": sorted(t.name for t in group.targets)}
|
||||
|
||||
await _audit("ansible.group.updated", "ansible_group", group_id, detail)
|
||||
return redirect(url_for("ansible_inventory.group_detail", group_id=group_id))
|
||||
|
||||
|
||||
@@ -136,8 +156,11 @@ async def group_delete(group_id: str):
|
||||
select(AnsibleGroup).where(AnsibleGroup.id == group_id)
|
||||
)
|
||||
group = result.scalar_one_or_none()
|
||||
if group is not None:
|
||||
await db.delete(group)
|
||||
if group is None:
|
||||
return redirect(url_for("ansible_inventory.groups_list"))
|
||||
name = group.name
|
||||
await db.delete(group)
|
||||
await _audit("ansible.group.deleted", "ansible_group", group_id, {"name": name})
|
||||
return redirect(url_for("ansible_inventory.groups_list"))
|
||||
|
||||
|
||||
@@ -171,12 +194,18 @@ async def targets_create():
|
||||
except ValueError as exc:
|
||||
return str(exc), 400
|
||||
|
||||
err = _vars_refusal(ansible_vars)
|
||||
if err:
|
||||
return err, 400
|
||||
|
||||
target = AnsibleTarget(
|
||||
id=str(uuid.uuid4()), name=name, address=address, ansible_vars=ansible_vars
|
||||
)
|
||||
async with current_app.db_sessionmaker() as db:
|
||||
async with db.begin():
|
||||
db.add(target)
|
||||
await _audit("ansible.target.created", "ansible_target", target.id,
|
||||
{"name": name, "address": address, "vars": sorted(ansible_vars)})
|
||||
return redirect(url_for("ansible_inventory.targets_list"))
|
||||
|
||||
|
||||
@@ -225,6 +254,9 @@ async def target_update(target_id: str):
|
||||
target = result.scalar_one_or_none()
|
||||
if target is None:
|
||||
return "Target not found", 404
|
||||
err = _vars_refusal(ansible_vars, target.ansible_vars)
|
||||
if err:
|
||||
return err, 400
|
||||
|
||||
name = form.get("name", target.name).strip()
|
||||
address = form.get("address", target.address).strip()
|
||||
@@ -238,7 +270,10 @@ async def target_update(target_id: str):
|
||||
all_groups_result = await db.execute(select(AnsibleGroup))
|
||||
group_map = {g.id: g for g in all_groups_result.scalars().all()}
|
||||
target.groups = [group_map[gid] for gid in new_group_ids if gid in group_map]
|
||||
detail = {"name": target.name, "address": target.address, "vars": sorted(ansible_vars),
|
||||
"groups": sorted(g.name for g in target.groups)}
|
||||
|
||||
await _audit("ansible.target.updated", "ansible_target", target_id, detail)
|
||||
return redirect(url_for("ansible_inventory.target_detail", target_id=target_id))
|
||||
|
||||
|
||||
@@ -251,6 +286,9 @@ async def target_delete(target_id: str):
|
||||
select(AnsibleTarget).where(AnsibleTarget.id == target_id)
|
||||
)
|
||||
target = result.scalar_one_or_none()
|
||||
if target is not None:
|
||||
await db.delete(target)
|
||||
if target is None:
|
||||
return redirect(url_for("ansible_inventory.targets_list"))
|
||||
name = target.name
|
||||
await db.delete(target)
|
||||
await _audit("ansible.target.deleted", "ansible_target", target_id, {"name": name})
|
||||
return redirect(url_for("ansible_inventory.targets_list"))
|
||||
|
||||
Reference in New Issue
Block a user