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 uuid
|
||||||
import yaml
|
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 import select
|
||||||
from sqlalchemy.orm import selectinload
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
|
from steward.ansible import guards
|
||||||
from steward.auth.middleware import require_role
|
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.ansible_inventory import AnsibleGroup, AnsibleTarget
|
||||||
from steward.models.users import UserRole
|
from steward.models.users import UserRole
|
||||||
|
|
||||||
@@ -30,6 +32,12 @@ def _parse_ansible_vars(raw: str) -> dict:
|
|||||||
return parsed
|
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
|
# Groups
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -59,10 +67,16 @@ async def groups_create():
|
|||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
return str(exc), 400
|
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)
|
group = AnsibleGroup(id=str(uuid.uuid4()), name=name, ansible_vars=ansible_vars)
|
||||||
async with current_app.db_sessionmaker() as db:
|
async with current_app.db_sessionmaker() as db:
|
||||||
async with db.begin():
|
async with db.begin():
|
||||||
db.add(group)
|
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"))
|
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()
|
group = result.scalar_one_or_none()
|
||||||
if group is None:
|
if group is None:
|
||||||
return "Group not found", 404
|
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()
|
name = form.get("name", group.name).strip()
|
||||||
if name:
|
if name:
|
||||||
@@ -123,7 +140,10 @@ async def group_update(group_id: str):
|
|||||||
).scalars().all()
|
).scalars().all()
|
||||||
target_map = {t.id: t for t in all_targets}
|
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]
|
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))
|
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)
|
select(AnsibleGroup).where(AnsibleGroup.id == group_id)
|
||||||
)
|
)
|
||||||
group = result.scalar_one_or_none()
|
group = result.scalar_one_or_none()
|
||||||
if group is not None:
|
if group is None:
|
||||||
|
return redirect(url_for("ansible_inventory.groups_list"))
|
||||||
|
name = group.name
|
||||||
await db.delete(group)
|
await db.delete(group)
|
||||||
|
await _audit("ansible.group.deleted", "ansible_group", group_id, {"name": name})
|
||||||
return redirect(url_for("ansible_inventory.groups_list"))
|
return redirect(url_for("ansible_inventory.groups_list"))
|
||||||
|
|
||||||
|
|
||||||
@@ -171,12 +194,18 @@ async def targets_create():
|
|||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
return str(exc), 400
|
return str(exc), 400
|
||||||
|
|
||||||
|
err = _vars_refusal(ansible_vars)
|
||||||
|
if err:
|
||||||
|
return err, 400
|
||||||
|
|
||||||
target = AnsibleTarget(
|
target = AnsibleTarget(
|
||||||
id=str(uuid.uuid4()), name=name, address=address, ansible_vars=ansible_vars
|
id=str(uuid.uuid4()), name=name, address=address, ansible_vars=ansible_vars
|
||||||
)
|
)
|
||||||
async with current_app.db_sessionmaker() as db:
|
async with current_app.db_sessionmaker() as db:
|
||||||
async with db.begin():
|
async with db.begin():
|
||||||
db.add(target)
|
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"))
|
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()
|
target = result.scalar_one_or_none()
|
||||||
if target is None:
|
if target is None:
|
||||||
return "Target not found", 404
|
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()
|
name = form.get("name", target.name).strip()
|
||||||
address = form.get("address", target.address).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))
|
all_groups_result = await db.execute(select(AnsibleGroup))
|
||||||
group_map = {g.id: g for g in all_groups_result.scalars().all()}
|
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]
|
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))
|
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)
|
select(AnsibleTarget).where(AnsibleTarget.id == target_id)
|
||||||
)
|
)
|
||||||
target = result.scalar_one_or_none()
|
target = result.scalar_one_or_none()
|
||||||
if target is not None:
|
if target is None:
|
||||||
|
return redirect(url_for("ansible_inventory.targets_list"))
|
||||||
|
name = target.name
|
||||||
await db.delete(target)
|
await db.delete(target)
|
||||||
|
await _audit("ansible.target.deleted", "ansible_target", target_id, {"name": name})
|
||||||
return redirect(url_for("ansible_inventory.targets_list"))
|
return redirect(url_for("ansible_inventory.targets_list"))
|
||||||
|
|||||||
@@ -8,9 +8,10 @@ from quart import (
|
|||||||
)
|
)
|
||||||
from sqlalchemy import select
|
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.ansible.inventory_gen import scope_labels
|
||||||
from steward.auth.middleware import require_role
|
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 import AnsibleRun, AnsibleRunStatus
|
||||||
from steward.models.ansible_schedule import AnsibleSchedule
|
from steward.models.ansible_schedule import AnsibleSchedule
|
||||||
from steward.models.users import User, UserRole
|
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]:
|
def _parse_run_params(form) -> tuple[dict | None, dict | None, str | None]:
|
||||||
"""Build executor params + the unpersisted secret-vars dict from form fields.
|
"""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
|
return "playbook_path is required", 400
|
||||||
|
|
||||||
params_or_none, secret_vars, err = _parse_run_params(form)
|
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:
|
if err:
|
||||||
return err, 400
|
return err, 400
|
||||||
|
|
||||||
@@ -251,6 +280,10 @@ async def create_run():
|
|||||||
return err, 404
|
return err, 404
|
||||||
if err:
|
if err:
|
||||||
return err, 400
|
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(
|
return await render_template(
|
||||||
"ansible/run_started.html",
|
"ansible/run_started.html",
|
||||||
@@ -323,6 +356,8 @@ async def cancel_run(run_id: str):
|
|||||||
return "Not found", 404
|
return "Not found", 404
|
||||||
if run.status in (AnsibleRunStatus.running, AnsibleRunStatus.queued):
|
if run.status in (AnsibleRunStatus.running, AnsibleRunStatus.queued):
|
||||||
executor.cancel_run(run_id)
|
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))
|
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
|
}, 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")
|
@ansible_bp.post("/schedules")
|
||||||
@require_role(UserRole.operator)
|
@require_role(UserRole.operator)
|
||||||
async def create_schedule():
|
async def create_schedule():
|
||||||
@@ -448,9 +492,14 @@ async def create_schedule():
|
|||||||
fields, err = _schedule_form_fields(form)
|
fields, err = _schedule_form_fields(form)
|
||||||
if err:
|
if err:
|
||||||
return err, 400
|
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 current_app.db_sessionmaker() as db:
|
||||||
async with db.begin():
|
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"))
|
return redirect(url_for("ansible.schedules"))
|
||||||
|
|
||||||
|
|
||||||
@@ -466,8 +515,14 @@ async def update_schedule(schedule_id: str):
|
|||||||
sched = await db.get(AnsibleSchedule, schedule_id)
|
sched = await db.get(AnsibleSchedule, schedule_id)
|
||||||
if sched is None:
|
if sched is None:
|
||||||
return "Not found", 404
|
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():
|
for k, v in fields.items():
|
||||||
setattr(sched, k, v)
|
setattr(sched, k, v)
|
||||||
|
await _audit("ansible.schedule.updated", "ansible_schedule", schedule_id, _schedule_detail(fields))
|
||||||
return redirect(url_for("ansible.schedules"))
|
return redirect(url_for("ansible.schedules"))
|
||||||
|
|
||||||
|
|
||||||
@@ -480,6 +535,9 @@ async def toggle_schedule(schedule_id: str):
|
|||||||
if sched is None:
|
if sched is None:
|
||||||
return "Not found", 404
|
return "Not found", 404
|
||||||
sched.enabled = not sched.enabled
|
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"))
|
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 current_app.db_sessionmaker() as db:
|
||||||
async with db.begin():
|
async with db.begin():
|
||||||
sched = await db.get(AnsibleSchedule, schedule_id)
|
sched = await db.get(AnsibleSchedule, schedule_id)
|
||||||
if sched is not None:
|
if sched is None:
|
||||||
|
return redirect(url_for("ansible.schedules"))
|
||||||
|
name = sched.name
|
||||||
await db.delete(sched)
|
await db.delete(sched)
|
||||||
|
await _audit("ansible.schedule.deleted", "ansible_schedule", schedule_id, {"name": name})
|
||||||
return redirect(url_for("ansible.schedules"))
|
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_at = datetime.now(timezone.utc)
|
||||||
fresh.last_run_id = run.id if run else None
|
fresh.last_run_id = run.id if run else None
|
||||||
fresh.last_error = err
|
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:
|
if run:
|
||||||
return redirect(url_for("ansible.run_detail", run_id=run.id))
|
return redirect(url_for("ansible.run_detail", run_id=run.id))
|
||||||
return redirect(url_for("ansible.schedules"))
|
return redirect(url_for("ansible.schedules"))
|
||||||
@@ -595,6 +658,9 @@ async def save_playbook():
|
|||||||
content=content, error=err,
|
content=content, error=err,
|
||||||
), 400
|
), 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(
|
return redirect(url_for(
|
||||||
"ansible.view_playbook", source_name=source_name, playbook_path=playbook_path))
|
"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())
|
source = _editable_source((form.get("source_name", "") or "").strip())
|
||||||
if source is None:
|
if source is None:
|
||||||
return "Source is not editable", 400
|
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:
|
if not ok:
|
||||||
return err, 400
|
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"))
|
return redirect(url_for("ansible.browse"))
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import asyncio
|
|||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime, timezone
|
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 (
|
from steward.ansible.inventory_gen import (
|
||||||
fetch_scope_targets, generate_inventory, inventory_to_yaml,
|
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)
|
source = next((s for s in sources if s["name"] == source_name), None)
|
||||||
if source is None:
|
if source is None:
|
||||||
return None, None, "Source not found"
|
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
|
inventory_path: str | None = None
|
||||||
if inventory_content is not None:
|
if inventory_content is not None:
|
||||||
@@ -55,11 +62,8 @@ async def trigger_run(
|
|||||||
async with app.db_sessionmaker() as db:
|
async with app.db_sessionmaker() as db:
|
||||||
targets = await fetch_scope_targets(db, inventory_scope)
|
targets = await fetch_scope_targets(db, inventory_scope)
|
||||||
inventory_content = inventory_to_yaml(generate_inventory(targets))
|
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:
|
else:
|
||||||
return None, source, "Invalid inventory_scope"
|
inventory_path = inventory_scope.split(":", 2)[2]
|
||||||
|
|
||||||
run_id = str(uuid.uuid4())
|
run_id = str(uuid.uuid4())
|
||||||
run = AnsibleRun(
|
run = AnsibleRun(
|
||||||
|
|||||||
@@ -38,3 +38,11 @@ async def log_audit(
|
|||||||
))
|
))
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("Failed to write audit event action=%r", action)
|
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
|
# Shared parser: discovered var__ fields → extra_vars_map, secret__ → secret_vars
|
||||||
# (unpersisted), plus tags/check. No --limit — there's exactly one host.
|
# (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)
|
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:
|
if err:
|
||||||
return err, 400
|
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", ""),
|
await log_audit(current_app, session.get("user_id"), session.get("username", ""),
|
||||||
"ansible.host_run", entity_type="host", entity_id=host.name,
|
"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))
|
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 import select
|
||||||
from sqlalchemy.orm import selectinload
|
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.inventory_gen import scope_labels
|
||||||
from steward.ansible.runner import trigger_run
|
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.alerts import AlertRule
|
||||||
from steward.models.ansible import AnsibleRun, AnsibleRunStatus
|
from steward.models.ansible import AnsibleRun, AnsibleRunStatus
|
||||||
from steward.models.ansible_inventory import AnsibleGroup, AnsibleTarget
|
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
|
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:
|
def build_params(plain: dict, limit: str, tags: str, check: bool) -> dict:
|
||||||
params: dict = {}
|
params: dict = {}
|
||||||
if plain:
|
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). "
|
raise ValueError(f"{playbook} is flagged destructive by its author (steward:confirm). "
|
||||||
"Run it with check=true first; to apply, pass check=false and "
|
"Run it with check=true first; to apply, pass check=false and "
|
||||||
"confirmed=true. Nothing was run.")
|
"confirmed=true. Nothing was run.")
|
||||||
|
check_caller_vars(extra_vars)
|
||||||
variables = src_module.discover_playbook_variables(content)
|
variables = src_module.discover_playbook_variables(content)
|
||||||
extra_vars, filled = steward_defaults(s, variables, extra_vars)
|
extra_vars, filled = steward_defaults(s, variables, extra_vars)
|
||||||
plain, secret = split_vars(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():
|
if not (name or "").strip():
|
||||||
raise ValueError("a schedule needs a name.")
|
raise ValueError("a schedule needs a name.")
|
||||||
|
check_caller_vars(extra_vars)
|
||||||
async with db_session() as db, db.begin():
|
async with db_session() as db, db.begin():
|
||||||
fields = await _schedule_fields(db, source, playbook, scope, extra_vars, limit, tags, check)
|
fields = await _schedule_fields(db, source, playbook, scope, extra_vars, limit, tags, check)
|
||||||
sc = AnsibleSchedule(name=name.strip()[:128], interval_seconds=_interval(interval_s),
|
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():
|
async with db_session() as db, db.begin():
|
||||||
sc = pick(schedule, await _schedules(db), kind="schedule", list_tool="list_schedules")
|
sc = pick(schedule, await _schedules(db), kind="schedule", list_tool="list_schedules")
|
||||||
params = dict(sc.params or {})
|
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
|
cur_scope = sc.inventory_scope
|
||||||
if cur_scope.startswith("repo:"):
|
if cur_scope.startswith("repo:"):
|
||||||
cur_scope = "inventory:" + cur_scope.rsplit(":", 1)[-1]
|
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:
|
finally:
|
||||||
_context._app_ctx.reset(a)
|
_context._app_ctx.reset(a)
|
||||||
_context._base_url_ctx.reset(u)
|
_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)
|
_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():
|
def test_every_post_form_carries_the_token():
|
||||||
files = sorted((_REPO / "steward" / "templates").rglob("*.html")) + \
|
files = sorted((_REPO / "steward" / "templates").rglob("*.html")) + \
|
||||||
sorted((_REPO / "plugins").rglob("templates/**/*.html"))
|
sorted((_REPO / "plugins").rglob("templates/**/*.html"))
|
||||||
missing = []
|
assert files
|
||||||
for f in files:
|
missing = [f"{f.relative_to(_REPO)}:{line}" for f in files
|
||||||
text = f.read_text()
|
for line in forms_missing_token(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 not missing, "POST forms without {{ csrf_field() }}: " + ", ".join(missing)
|
assert not missing, "POST forms without {{ csrf_field() }}: " + ", ".join(missing)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+29
-12
@@ -13,28 +13,45 @@ _REPO = pathlib.Path(steward.__file__).parent.parent
|
|||||||
_ROUTE_METHODS = ("get", "post", "put", "patch", "delete", "route", "websocket")
|
_ROUTE_METHODS = ("get", "post", "put", "patch", "delete", "route", "websocket")
|
||||||
|
|
||||||
|
|
||||||
def _route_decorated_views():
|
def route_views(source: str):
|
||||||
files = [p for root in ("steward", "plugins") for p in (_REPO / root).rglob("*.py")
|
"""(function node, decorator texts) for each route-decorated function."""
|
||||||
if "migrations" not in p.parts]
|
for node in ast.walk(ast.parse(source)):
|
||||||
for path in files:
|
|
||||||
tree = ast.parse(path.read_text())
|
|
||||||
for node in ast.walk(tree):
|
|
||||||
if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
||||||
continue
|
continue
|
||||||
decorators = [ast.unparse(d) for d in node.decorator_list]
|
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):
|
if any(f".{m}('/" in d for d in decorators for m in _ROUTE_METHODS):
|
||||||
yield path, node, decorators
|
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():
|
def test_every_route_says_who_may_reach_it():
|
||||||
unmarked = [f"{p.relative_to(_REPO)}:{n.lineno} {n.name}"
|
unmarked = [f"{p.relative_to(_REPO)} {name}" for p, text in _sources()
|
||||||
for p, n, decs in _route_decorated_views()
|
for name in unmarked_routes(text)]
|
||||||
if not any(d.startswith(("require_role(", "public_route(")) for d in decs)]
|
|
||||||
assert not unmarked, "routes with neither require_role nor public_route: " + ", ".join(unmarked)
|
assert not unmarked, "routes with neither require_role nor public_route: " + ", ".join(unmarked)
|
||||||
|
|
||||||
|
|
||||||
def test_the_scan_sees_routes():
|
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
|
assert {"login", "health", "ingest", "widget_table", "public_share"} <= names
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user