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:
@@ -0,0 +1,86 @@
|
||||
"""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}
|
||||
@@ -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"))
|
||||
|
||||
@@ -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"))
|
||||
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ import asyncio
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
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 (
|
||||
fetch_scope_targets, generate_inventory, inventory_to_yaml,
|
||||
)
|
||||
@@ -47,6 +47,13 @@ async def trigger_run(
|
||||
source = next((s for s in sources if s["name"] == source_name), None)
|
||||
if source is None:
|
||||
return None, None, "Source not found"
|
||||
# Every run path (web, MCP, alerts, schedules, plugins) arrives here, so
|
||||
# this is where a run is held to what its source lists (ansible/guards.py).
|
||||
err = guards.check_playbook(source, playbook_path)
|
||||
if err is None and inventory_content is None:
|
||||
err = guards.check_inventory_scope(source, inventory_scope)
|
||||
if err:
|
||||
return None, source, err
|
||||
|
||||
inventory_path: str | None = None
|
||||
if inventory_content is not None:
|
||||
@@ -55,11 +62,8 @@ async def trigger_run(
|
||||
async with app.db_sessionmaker() as db:
|
||||
targets = await fetch_scope_targets(db, inventory_scope)
|
||||
inventory_content = inventory_to_yaml(generate_inventory(targets))
|
||||
elif inventory_scope.startswith("repo:"):
|
||||
parts = inventory_scope.split(":", 2)
|
||||
inventory_path = parts[2] if len(parts) == 3 else ""
|
||||
else:
|
||||
return None, source, "Invalid inventory_scope"
|
||||
inventory_path = inventory_scope.split(":", 2)[2]
|
||||
|
||||
run_id = str(uuid.uuid4())
|
||||
run = AnsibleRun(
|
||||
|
||||
@@ -38,3 +38,11 @@ async def log_audit(
|
||||
))
|
||||
except Exception:
|
||||
logger.exception("Failed to write audit event action=%r", action)
|
||||
|
||||
|
||||
async def audit_request(action: str, entity_type: str | None = None,
|
||||
entity_id: str | None = None, detail: dict | None = None) -> None:
|
||||
"""log_audit for the logged-in user of the current web request."""
|
||||
from quart import current_app, session
|
||||
await log_audit(current_app, session.get("user_id"), session.get("username", ""),
|
||||
action, entity_type=entity_type, entity_id=entity_id, detail=detail)
|
||||
|
||||
@@ -221,8 +221,11 @@ async def run_playbook(host_id: str):
|
||||
|
||||
# Shared parser: discovered var__ fields → extra_vars_map, secret__ → secret_vars
|
||||
# (unpersisted), plus tags/check. No --limit — there's exactly one host.
|
||||
from steward.ansible.routes import _parse_run_params
|
||||
from steward.ansible.routes import _parse_run_params, _run_vars, check_run_form
|
||||
params_or_none, secret_vars, err = _parse_run_params(form)
|
||||
if err:
|
||||
return err, 400
|
||||
err = check_run_form(form, source, playbook, params_or_none, secret_vars)
|
||||
if err:
|
||||
return err, 400
|
||||
|
||||
@@ -254,7 +257,9 @@ async def run_playbook(host_id: str):
|
||||
|
||||
await log_audit(current_app, session.get("user_id"), session.get("username", ""),
|
||||
"ansible.host_run", entity_type="host", entity_id=host.name,
|
||||
detail={"playbook": playbook, "source": source_name})
|
||||
detail={"playbook": playbook, "source": source_name, "run_id": run_id,
|
||||
"check": bool((params_or_none or {}).get("check")),
|
||||
"vars": sorted(_run_vars(params_or_none, secret_vars))})
|
||||
return redirect(url_for("ansible.run_detail", run_id=run_id))
|
||||
|
||||
|
||||
|
||||
@@ -19,10 +19,10 @@ from datetime import timedelta
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
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.ansible.runner import trigger_run
|
||||
from steward.mcp._context import current_caller, mcp_app, public_url
|
||||
from steward.mcp._context import current_caller, current_caller_or_none, mcp_app, public_url
|
||||
from steward.models.alerts import AlertRule
|
||||
from steward.models.ansible import AnsibleRun, AnsibleRunStatus
|
||||
from steward.models.ansible_inventory import AnsibleGroup, AnsibleTarget
|
||||
@@ -118,6 +118,17 @@ def split_vars(variables: list[dict], extra_vars: dict | None) -> tuple[dict, di
|
||||
return plain, secret
|
||||
|
||||
|
||||
def check_caller_vars(extra_vars: dict | None, previous: dict | None = None) -> None:
|
||||
"""Refuse variables this caller's role may not set (ansible/guards.py):
|
||||
connection-type ansible_* names and Jinja in values are admin-only. On an
|
||||
update only what changes is held to it."""
|
||||
caller = current_caller_or_none()
|
||||
err = guards.check_vars_for_role(guards.changed_vars(extra_vars, previous),
|
||||
caller.role if caller else None)
|
||||
if err:
|
||||
raise ValueError(f"{err} Nothing was run or saved.")
|
||||
|
||||
|
||||
def build_params(plain: dict, limit: str, tags: str, check: bool) -> dict:
|
||||
params: dict = {}
|
||||
if plain:
|
||||
@@ -437,6 +448,7 @@ async def run_playbook(source: str, playbook: str, scope: str = "all",
|
||||
raise ValueError(f"{playbook} is flagged destructive by its author (steward:confirm). "
|
||||
"Run it with check=true first; to apply, pass check=false and "
|
||||
"confirmed=true. Nothing was run.")
|
||||
check_caller_vars(extra_vars)
|
||||
variables = src_module.discover_playbook_variables(content)
|
||||
extra_vars, filled = steward_defaults(s, variables, extra_vars)
|
||||
plain, secret = split_vars(variables, extra_vars)
|
||||
@@ -564,6 +576,7 @@ async def create_schedule(name: str, source: str, playbook: str, interval_s: int
|
||||
"""
|
||||
if not (name or "").strip():
|
||||
raise ValueError("a schedule needs a name.")
|
||||
check_caller_vars(extra_vars)
|
||||
async with db_session() as db, db.begin():
|
||||
fields = await _schedule_fields(db, source, playbook, scope, extra_vars, limit, tags, check)
|
||||
sc = AnsibleSchedule(name=name.strip()[:128], interval_seconds=_interval(interval_s),
|
||||
@@ -589,6 +602,8 @@ async def update_schedule(schedule: str, name: str | None = None, source: str |
|
||||
async with db_session() as db, db.begin():
|
||||
sc = pick(schedule, await _schedules(db), kind="schedule", list_tool="list_schedules")
|
||||
params = dict(sc.params or {})
|
||||
if extra_vars is not None:
|
||||
check_caller_vars(extra_vars, params.get("extra_vars_map"))
|
||||
cur_scope = sc.inventory_scope
|
||||
if cur_scope.startswith("repo:"):
|
||||
cur_scope = "inventory:" + cur_scope.rsplit(":", 1)[-1]
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
"""A run is held to what its source lists, and the variables that would run
|
||||
commands on the Steward server are admin-only."""
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from steward.ansible import guards, runner
|
||||
from steward.models.users import UserRole
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def source(tmp_path):
|
||||
(tmp_path / "site.yml").write_text("- hosts: all\n tasks: []\n")
|
||||
(tmp_path / "roles").mkdir()
|
||||
(tmp_path / "roles" / "deep.yml").write_text("- hosts: all\n")
|
||||
(tmp_path / "inventory.yml").write_text("all: {}\n")
|
||||
return {"name": "local", "path": str(tmp_path)}
|
||||
|
||||
|
||||
def test_playbook_must_be_listed_by_its_source(source, tmp_path):
|
||||
assert guards.check_playbook(source, "site.yml") is None
|
||||
assert guards.check_playbook(source, "roles/deep.yml") is None
|
||||
(tmp_path.parent / "outside.yml").write_text("- hosts: localhost\n")
|
||||
for path in ("../outside.yml", "/etc/passwd", str(tmp_path / "site.yml"), "missing.yml"):
|
||||
assert "has no playbook" in guards.check_playbook(source, path)
|
||||
|
||||
|
||||
def test_inventory_scope(source):
|
||||
assert guards.check_inventory_scope(source, "steward:all") is None
|
||||
assert guards.check_inventory_scope(source, "repo:local:inventory.yml") is None
|
||||
assert "no inventory file" in guards.check_inventory_scope(source, "repo:local:../../etc/hosts")
|
||||
assert guards.check_inventory_scope(source, "/tmp/inv") == "Invalid inventory_scope"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("variables", [
|
||||
{"ansible_host": "10.0.0.5", "ansible_user": "steward", "ansible_become": True},
|
||||
{"release": "1.2", "ansible_port": 2222},
|
||||
{},
|
||||
])
|
||||
def test_operator_may_set_target_vars(variables):
|
||||
assert guards.check_vars_for_role(variables, UserRole.operator) is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize("variables,word", [
|
||||
({"ansible_connection": "local"}, "ansible_connection"),
|
||||
({"ansible_ssh_common_args": "-o ProxyCommand=sh -c id"}, "ansible_ssh_common_args"),
|
||||
({"ansible_python_interpreter": "/tmp/x"}, "ansible_python_interpreter"),
|
||||
({"ansible_user": "{{ lookup('pipe', 'id') }}"}, "ansible_user"),
|
||||
({"note": {"nested": ["{% raw %}"]}}, "note"),
|
||||
])
|
||||
def test_operator_may_not_set_controller_vars(variables, word):
|
||||
err = guards.check_vars_for_role(variables, UserRole.operator)
|
||||
assert err and word in err and "Only an admin" in err
|
||||
assert guards.check_vars_for_role(variables, None) == err
|
||||
assert guards.check_vars_for_role(variables, UserRole.admin) is None
|
||||
|
||||
|
||||
def test_only_changes_are_held_to_the_role():
|
||||
stored = {"ansible_ssh_common_args": "-J bastion", "ansible_user": "a"}
|
||||
edited = {"ansible_ssh_common_args": "-J bastion", "ansible_user": "b"}
|
||||
assert guards.changed_vars(edited, stored) == {"ansible_user": "b"}
|
||||
assert guards.check_vars_for_role(guards.changed_vars(edited, stored), UserRole.operator) is None
|
||||
edited["ansible_ssh_common_args"] = "-o ProxyCommand=x"
|
||||
assert guards.check_vars_for_role(guards.changed_vars(edited, stored), UserRole.operator)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_trigger_run_refuses_a_path_its_source_does_not_list(source, monkeypatch):
|
||||
monkeypatch.setattr(runner.src_module, "get_sources", lambda cfg: [source])
|
||||
app = SimpleNamespace(config={})
|
||||
run, src, err = await runner.trigger_run(app, source_name="local", playbook_path="../x.yml")
|
||||
assert run is None and "has no playbook" in err
|
||||
run, src, err = await runner.trigger_run(app, source_name="local", playbook_path="site.yml",
|
||||
inventory_scope="repo:local:/etc/hosts")
|
||||
assert run is None and "no inventory file" in err
|
||||
@@ -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)
|
||||
|
||||
+18
-8
@@ -14,17 +14,27 @@ _FORM_TAG = re.compile(r"<form\b[^>]*>", re.S | re.I)
|
||||
_POST = re.compile(r"""\bmethod\s*=\s*["']?post\b""", re.I)
|
||||
|
||||
|
||||
def forms_missing_token(text: str) -> list[int]:
|
||||
"""Line numbers of POST forms whose first child is not {{ csrf_field() }}."""
|
||||
return [text.count("\n", 0, m.start()) + 1 for m in _FORM_TAG.finditer(text)
|
||||
if _POST.search(m.group(0))
|
||||
and not text[m.end():].lstrip().startswith("{{ csrf_field() }}")]
|
||||
|
||||
|
||||
def test_the_form_guard_can_fail():
|
||||
broken = '<form\n method="post" action="/x">\n <input name="a">\n</form>'
|
||||
fixed = '<form method="POST" action="/x">{{ csrf_field() }}<input name="a"></form>'
|
||||
assert forms_missing_token(broken) == [1]
|
||||
assert forms_missing_token(fixed) == []
|
||||
assert forms_missing_token('<form method="get">') == []
|
||||
|
||||
|
||||
def test_every_post_form_carries_the_token():
|
||||
files = sorted((_REPO / "steward" / "templates").rglob("*.html")) + \
|
||||
sorted((_REPO / "plugins").rglob("templates/**/*.html"))
|
||||
missing = []
|
||||
for f in files:
|
||||
text = f.read_text()
|
||||
for m in _FORM_TAG.finditer(text):
|
||||
if _POST.search(m.group(0)) and \
|
||||
not text[m.end():].lstrip().startswith("{{ csrf_field() }}"):
|
||||
line = text.count("\n", 0, m.start()) + 1
|
||||
missing.append(f"{f.relative_to(_REPO)}:{line}")
|
||||
assert files
|
||||
missing = [f"{f.relative_to(_REPO)}:{line}" for f in files
|
||||
for line in forms_missing_token(f.read_text())]
|
||||
assert not missing, "POST forms without {{ csrf_field() }}: " + ", ".join(missing)
|
||||
|
||||
|
||||
|
||||
+32
-15
@@ -13,28 +13,45 @@ _REPO = pathlib.Path(steward.__file__).parent.parent
|
||||
_ROUTE_METHODS = ("get", "post", "put", "patch", "delete", "route", "websocket")
|
||||
|
||||
|
||||
def _route_decorated_views():
|
||||
files = [p for root in ("steward", "plugins") for p in (_REPO / root).rglob("*.py")
|
||||
if "migrations" not in p.parts]
|
||||
for path in files:
|
||||
tree = ast.parse(path.read_text())
|
||||
for node in ast.walk(tree):
|
||||
if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
||||
continue
|
||||
decorators = [ast.unparse(d) for d in node.decorator_list]
|
||||
if any(f".{m}('/" in d or f'.{m}("/' in d for d in decorators for m in _ROUTE_METHODS):
|
||||
yield path, node, decorators
|
||||
def route_views(source: str):
|
||||
"""(function node, decorator texts) for each route-decorated function."""
|
||||
for node in ast.walk(ast.parse(source)):
|
||||
if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
||||
continue
|
||||
decorators = [ast.unparse(d) for d in node.decorator_list]
|
||||
if any(f".{m}('/" in d for d in decorators for m in _ROUTE_METHODS):
|
||||
yield node, decorators
|
||||
|
||||
|
||||
def unmarked_routes(source: str) -> list[str]:
|
||||
return [n.name for n, decs in route_views(source)
|
||||
if not any(d.startswith(("require_role(", "public_route(")) for d in decs)]
|
||||
|
||||
|
||||
def _sources():
|
||||
for root in ("steward", "plugins"):
|
||||
for path in (_REPO / root).rglob("*.py"):
|
||||
if "migrations" not in path.parts:
|
||||
yield path, path.read_text()
|
||||
|
||||
|
||||
def test_the_route_guard_can_fail():
|
||||
source = (
|
||||
"@bp.get('/open')\nasync def open_view(): ...\n"
|
||||
"@bp.post('/closed')\n@require_role(UserRole.viewer)\nasync def closed(): ...\n"
|
||||
"@bp.get('/login')\n@public_route('login')\nasync def login(): ...\n"
|
||||
"def helper(): ...\n")
|
||||
assert unmarked_routes(source) == ["open_view"]
|
||||
|
||||
|
||||
def test_every_route_says_who_may_reach_it():
|
||||
unmarked = [f"{p.relative_to(_REPO)}:{n.lineno} {n.name}"
|
||||
for p, n, decs in _route_decorated_views()
|
||||
if not any(d.startswith(("require_role(", "public_route(")) for d in decs)]
|
||||
unmarked = [f"{p.relative_to(_REPO)} {name}" for p, text in _sources()
|
||||
for name in unmarked_routes(text)]
|
||||
assert not unmarked, "routes with neither require_role nor public_route: " + ", ".join(unmarked)
|
||||
|
||||
|
||||
def test_the_scan_sees_routes():
|
||||
names = {n.name for _, n, _ in _route_decorated_views()}
|
||||
names = {n.name for _, text in _sources() for n, _ in route_views(text)}
|
||||
assert {"login", "health", "ingest", "widget_table", "public_share"} <= names
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user