- /plugins/syslog/sources/<id>: the lines newest first, loading as you scroll, filtered by severity and worse, app, text and time, with day dividers and times to the second in the reader's zone. - The Syslog page links each source and shows its lines in the last hour. - A Syslog panel on a host's page and an SNMP device's: the newest lines of the sources bound to it or at its address. - MCP: list_syslog_sources and search_syslog (source, host, text, severity and worse, app, range, paging by next_before). - Shared: escape_like moves to steward/core/like.py and cut_middle to the MCP shaping module, replacing four inline copies; |when takes seconds=. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
75 lines
2.7 KiB
Python
75 lines
2.7 KiB
Python
"""The audit log: who changed what in Steward, including changes made through MCP."""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
|
|
from sqlalchemy import or_, select
|
|
|
|
from steward.core.like import escape_like
|
|
from steward.models.audit import AuditEvent
|
|
from steward.models.users import UserRole
|
|
|
|
from ._audit import mcp_username
|
|
from ._shape import age_s, bounded, clamp_limit, db_session, iso, lookback
|
|
|
|
|
|
async def list_audit_events(action: str = "", user: str = "", entity_type: str = "",
|
|
range: str = "7d", limit: int = 50) -> dict:
|
|
"""Changes recorded in Steward's audit log, newest first.
|
|
|
|
action: a prefix of the action name, e.g. "monitor." or "ansible.run";
|
|
empty for all.
|
|
user: a username, exactly; "system" for automated changes. Includes the
|
|
changes that user made through MCP, which are recorded as "<user> via MCP".
|
|
entity_type: e.g. "monitor", "host", "alert_rule"; empty for all.
|
|
range: "1h", "6h", "24h", "7d" (default) or "30d".
|
|
limit: default 50, max 500.
|
|
|
|
Answers "what changed before this broke?": line an event's `at` up against
|
|
a monitor's down_since or an alert's since.
|
|
"""
|
|
since, key = lookback(range)
|
|
limit = clamp_limit(limit, 50, 500)
|
|
stmt = (select(AuditEvent).where(AuditEvent.timestamp >= since)
|
|
.order_by(AuditEvent.timestamp.desc()).limit(limit + 1))
|
|
if action:
|
|
stmt = stmt.where(AuditEvent.action.like(escape_like(action) + "%", escape="\\"))
|
|
if user:
|
|
stmt = stmt.where(or_(AuditEvent.username == user,
|
|
AuditEvent.username == mcp_username(user)))
|
|
if entity_type:
|
|
stmt = stmt.where(AuditEvent.entity_type == entity_type)
|
|
async with db_session() as db:
|
|
rows = list((await db.execute(stmt)).scalars())
|
|
|
|
def _detail(raw):
|
|
if not raw:
|
|
return None
|
|
try:
|
|
return json.loads(raw)
|
|
except ValueError:
|
|
return raw[:500]
|
|
|
|
events = [
|
|
{
|
|
"at": iso(e.timestamp),
|
|
"age_s": age_s(e.timestamp),
|
|
"user": e.username,
|
|
"action": e.action,
|
|
"entity_type": e.entity_type,
|
|
"entity_id": e.entity_id,
|
|
"detail": _detail(e.detail_json),
|
|
}
|
|
for e in rows
|
|
]
|
|
out = {"range": key, **bounded(events, limit, key="events")}
|
|
if out["truncated"]:
|
|
out["total"] = None
|
|
out["more"] = (f"More than {limit} events in range. Pass a larger `limit` (max 500), "
|
|
"a shorter `range`, or filter by `action`, `user` or `entity_type`.")
|
|
return out
|
|
|
|
|
|
def register(register_tool) -> None:
|
|
register_tool(list_audit_events, role=UserRole.viewer, read_only=True)
|