feat(audit): every write route leaves an audit row

Groups, policies, scan schedules, finding assignment and suppression, and
every sync, scan, refresh and connection-test button wrote no audit row,
so "who did this, and what changed" had no answer for them.

Records that change now write one row naming each field old -> new
(audit_trail.record_change; a create passes before={}, a delete after={},
nothing changed writes nothing), in the same transaction as the change.
Defer, undefer and false-positive also record the date, reason and
suppression they set or clear; an admin's email change is logged.

Buttons take the route dependency audited("What"): one row once the
endpoint returned, before the response goes out, so a refused or raising
call writes none and a failing audit write is a 500 rather than a gap. It
keeps the query parameters and only the named body fields, because request
bodies carry passwords: the test-mail recipient and SMTP host, the syslog
target, the AI provider and base URL, the Nessus asset and scan.

A guard test fails for any new POST/PUT/PATCH/DELETE route that does not
audit itself. No new event types, so no migration: groups, policies and
schedules log as CONFIG_CHANGE, buttons as SCAN_TRIGGERED, told apart by
resource_type and the description.
This commit is contained in:
2026-10-26 11:03:31 +01:00
parent 69d9447de7
commit 236c00ada7
22 changed files with 597 additions and 99 deletions
+15
View File
@@ -552,6 +552,21 @@ user (sync, scheduler) stay as they are: no name, no IP — they read
old → new; a settings `PUT` writes `CONFIG_CHANGE` with the changed field
names and redacted old/new values, and nothing when the value is unchanged.
Every POST/PUT/PATCH/DELETE route audits itself, and
`tests/test_audit_user_actions.py` fails for a new one that does not. Two
helpers in `app/services/audit_trail.py`: `record_change` (one row naming each
field old → new; create passes `before={}`, delete `after={}`; nothing changed,
no row) for groups, policies, scan schedules, finding/asset assignment and
suppression; `record_action` for actions that change no single record. Buttons
(sync, scan, refresh, connection test) take the route dependency
`audited("What")` from `app/auth/dependencies.py`: one row once the endpoint
returned, before the response goes out (`scope="function"`) — a refused call
or one that raises writes none, a connection test answering `{"ok": false}`
is recorded as attempted — with the query parameters and only the named
`body_fields` (bodies carry passwords). Groups, policies and
schedules log as `CONFIG_CHANGE`, buttons as `SCAN_TRIGGERED`, both told apart
by `resource_type` and the description; no new enum values, so no migration.
### Custom CA certificates (internal PKI)
Integrations behind an internal-PKI certificate used to work only with
+38
View File
@@ -166,3 +166,41 @@ async def capture_request_origin(request: Request) -> None:
from app.models.audit_log import request_origin
request_origin.set((request.client.host if request.client else None,
request.headers.get("user-agent")))
def audited(what: str, event_type=None, resource_type: Optional[str] = None,
body_fields: tuple = ()):
"""Route dependency for an action button that changes no single record
(sync, scan, refresh, connection test): one audit row naming who started
`what`, written once the endpoint returned — a refused call or one that
raises writes none; a test that answers {"ok": false} counts as attempted. The query parameters go into the row, and of a JSON body only
`body_fields` ("a" or "a.b"), never the rest: bodies carry passwords.
With `resource_type`, the route's path id is the resource.
Returns the Depends itself: scope="function" writes the row before the
response goes out, so a failing write is a 500, not a silent gap."""
from starlette.concurrency import run_in_threadpool
from app.models.audit_log import AuditEventType
from app.services.audit_trail import record_action
async def _audit(request: Request, db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)):
yield
q = request.query_params
details = {k: q.getlist(k) if len(q.getlist(k)) > 1 else q[k] for k in q.keys()}
if body_fields:
body = await request.json() # cached from the endpoint's own parse
for path in body_fields:
value = body
for part in path.split("."):
value = value.get(part) if isinstance(value, dict) else None
if value is not None:
details[path] = value
resource_id = next(iter(request.path_params.values()), None) if resource_type else None
def _write():
record_action(db, current_user.id, event_type or AuditEventType.SCAN_TRIGGERED, what,
resource_type=resource_type, resource_id=resource_id, details=details)
db.commit()
await run_in_threadpool(_write)
return Depends(_audit, scope="function")
+4 -4
View File
@@ -6,7 +6,7 @@ from sqlalchemy.orm import Session
from app.database import get_db
from app.models.user import User
from app.auth.dependencies import get_current_user, RequireEditor
from app.auth.dependencies import get_current_user, RequireEditor, audited
router = APIRouter(prefix="/api/v1/advisories", tags=["Advisories"])
@@ -36,7 +36,7 @@ def advisory_feeds(
return cached
@router.post("/feeds/refresh")
@router.post("/feeds/refresh", dependencies=[audited("Advisory feeds refresh")])
# Sync def → worker threadpool; N feed fetches are blocking I/O.
def refresh_advisory_feeds(
db: Session = Depends(get_db),
@@ -71,7 +71,7 @@ def kev_recent(
}
@router.post("/kev/refresh")
@router.post("/kev/refresh", dependencies=[audited("CISA KEV refresh")])
# Sync def → worker threadpool; three catalog downloads are blocking I/O.
def refresh_kev(
db: Session = Depends(get_db),
@@ -106,7 +106,7 @@ def kev_alert_preview(
}
@router.post("/kev-alerts/run")
@router.post("/kev-alerts/run", dependencies=[audited("KEV alert run")])
# Sync def → worker threadpool; blocking SMTP must stay off the event loop.
def run_kev_alerts_now(
db: Session = Depends(get_db),
+19 -22
View File
@@ -17,7 +17,8 @@ from app.models.asset import Asset, AssetSource, AssetStatus, ContainerPlacement
from app.models.policy import Policy
from app.models.vulnerability import Vulnerability, VulnerabilityStatus
from app.models.audit_log import AuditLog, AuditEventType
from app.auth.dependencies import get_current_user, RequireEditor, RequireAdmin
from app.auth.dependencies import get_current_user, RequireEditor, RequireAdmin, audited
from app.services.audit_trail import record_change
import csv
import io
import logging
@@ -168,22 +169,13 @@ def _asset_state(asset: Asset, fields) -> dict:
def _audit_asset_update(db: Session, user: User, asset: Asset, before: dict) -> None:
"""One ASSET_UPDATED row naming every changed field, old → new. Nothing
changed, no row."""
after = _asset_state(asset, before)
changed = [f for f in before if before[f] != after[f]]
if not changed:
return
shown = "; ".join(f"{f}: {'–' if before[f] is None else before[f]} → "
f"{'–' if after[f] is None else after[f]}" for f in changed)
db.add(AuditLog(
user_id=user.id,
event_type=AuditEventType.ASSET_UPDATED,
event_description=f"Asset updated: {asset.hostname} — {shown}"[:500],
resource_type="asset",
resource_id=str(asset.id),
old_value=json.dumps({f: before[f] for f in changed}, default=str),
new_value=json.dumps({f: after[f] for f in changed}, default=str),
timestamp=datetime.now(),
))
record_change(db, user.id, AuditEventType.ASSET_UPDATED, "asset", asset.id,
f"Asset updated: {asset.hostname}", before, _asset_state(asset, before))
def _assignment(asset: Asset) -> dict:
return {"assigned_user": asset.assigned_user.username if asset.assigned_user else None,
"groups": sorted(g.name for g in asset.groups)}
# ============================================
@@ -250,7 +242,7 @@ def bulk_update_assets(
return {"message": f"{count} assets successfully updated", "count": count}
@router.post("/reconcile-lifecycle")
@router.post("/reconcile-lifecycle", dependencies=[audited("Asset lifecycle reconcile")])
def reconcile_lifecycle(
db: Session = Depends(get_db),
current_user: User = Depends(RequireEditor),
@@ -469,7 +461,7 @@ def asset_containers(
for a, containers, n in rows]}
@router.post("/refresh-exposure")
@router.post("/refresh-exposure", dependencies=[audited("Asset exposure refresh")])
def refresh_exposure(
db: Session = Depends(get_db),
current_user: User = Depends(RequireEditor),
@@ -1101,7 +1093,8 @@ def assign_asset(
user = db.query(User).filter(User.id == assign_data.user_id).first()
if not user:
raise HTTPException(status_code=404, detail="User not found")
before = _assignment(asset)
asset.assigned_user_id = assign_data.user_id
# Handle single group assignment (legacy support)
@@ -1113,7 +1106,11 @@ def assign_asset(
group = db.query(Group).filter(Group.id == assign_data.group_id).first()
if group:
asset.groups = [group]
db.flush()
db.expire(asset, ["assigned_user"]) # reload it from the new id
record_change(db, current_user.id, AuditEventType.ASSET_UPDATED, "asset", asset.id,
f"Asset updated: {asset.hostname}", before, _assignment(asset))
db.commit()
db.refresh(asset)
@@ -1161,7 +1158,7 @@ from app.integrations.wazuh_client import WazuhClient, WazuhAPIError
from app.models.setting import Setting
from app.services.asset_matching import like_literal, pick_one
@router.post("/sync_wazuh", response_model=dict)
@router.post("/sync_wazuh", response_model=dict, dependencies=[audited("Wazuh asset sync")])
def sync_wazuh_assets(
db: Session = Depends(get_db),
current_user: User = Depends(RequireEditor)
+5 -1
View File
@@ -29,6 +29,7 @@ from app.auth.jwt_handler import (
TOKEN_TYPE_MFA_SETUP,
)
from app.auth.dependencies import get_current_user, RequireAdmin
from app.services.audit_trail import record_change
from app.auth.orchestrator import AuthOrchestrator
from app.auth.strategies.base import AuthError
from app.auth import totp as totp_helper
@@ -1142,7 +1143,10 @@ def update_user(
existing = db.query(User).filter(User.email == user_data.email).first()
if existing and existing.id != user_id:
raise HTTPException(status_code=400, detail="Email already in use")
record_change(db, current_user.id, AuditEventType.CONFIG_CHANGE, "user", user.id,
f"User updated: {user.username}",
{"email": user.email}, {"email": user_data.email})
user.email = user_data.email
changes.append("email")
+4 -4
View File
@@ -27,7 +27,7 @@ from fastapi import APIRouter, Depends, HTTPException, Request, status
from pydantic import BaseModel, Field, field_validator
from sqlalchemy.orm import Session
from app.auth.dependencies import RequireAdmin
from app.auth.dependencies import RequireAdmin, audited
from app.auth.role_mapper import SETTINGS_KEY as ROLE_MAP_SETTINGS_KEY
from app.database import get_db
from app.models.audit_log import AuditEventType, AuditLog
@@ -228,7 +228,7 @@ def put_role_mappings(
# Provider test endpoints
# ============================================
@router.post("/ldap/test")
@router.post("/ldap/test", dependencies=[audited("LDAP connection test")])
def test_ldap(
payload: LdapTestRequest,
db: Session = Depends(get_db),
@@ -277,7 +277,7 @@ def test_ldap(
return out
@router.post("/oidc/test")
@router.post("/oidc/test", dependencies=[audited("OIDC connection test")])
def test_oidc(
current_user: User = Depends(RequireAdmin),
):
@@ -313,7 +313,7 @@ def test_oidc(
}
@router.post("/saml/test")
@router.post("/saml/test", dependencies=[audited("SAML configuration test")])
def test_saml(
current_user: User = Depends(RequireAdmin),
):
+13 -5
View File
@@ -22,7 +22,7 @@ from pydantic import BaseModel
from sqlalchemy import func
from sqlalchemy.orm import Session
from app.auth.dependencies import RequireEditor, get_current_user, RequireAdmin
from app.auth.dependencies import RequireEditor, get_current_user, RequireAdmin, audited
from app.database import get_db
from app.models.asset import Asset
from app.models.compliance import (
@@ -35,6 +35,8 @@ from app.services.compliance_service import (
refresh_asset_compliance,
)
from app.services.compliance_impact_import import import_impact_csv_files
from app.models.audit_log import AuditEventType
from app.services.audit_trail import record_action
from app.services.urs_service import (
compute_urs, compute_urs_bulk, compute_urs_for_all, get_urs_trend,
)
@@ -247,7 +249,7 @@ def get_asset_policy_checks(
# ---------- mutation endpoints ----------
@router.post("/{asset_id:int}/refresh")
@router.post("/{asset_id:int}/refresh", dependencies=[audited("Compliance refresh", resource_type="asset")])
def refresh_one_asset(
asset_id: int,
db: Session = Depends(get_db),
@@ -285,7 +287,13 @@ async def import_compliance_impacts(
f"{f.filename or 'upload.csv'} ist groesser als "
f"{MAX_UPLOAD_BYTES // (1024 * 1024)} MB — abgelehnt")
payloads.append((f.filename or "upload.csv", blob))
return import_impact_csv_files(db, payloads)
result = import_impact_csv_files(db, payloads)
record_action(db, current_user.id, AuditEventType.CONFIG_CHANGE,
f"Compliance impact CSVs imported: {', '.join(name for name, _ in payloads)}",
resource_type="compliance_impact",
details={k: result[k] for k in ("parsed", "imported", "skipped_no_id", "skipped_not_cis")})
db.commit()
return result
@router.get("/impacts/stats")
@@ -398,7 +406,7 @@ def list_urs(
return rows[:limit]
@router.post("/urs/recompute-all")
@router.post("/urs/recompute-all", dependencies=[audited("URS recompute (all assets)")])
def recompute_all_urs(
avs_mode: str = Query("hybrid", pattern="^(hybrid|avg|max)$"),
db: Session = Depends(get_db),
@@ -410,7 +418,7 @@ def recompute_all_urs(
return stats
@router.post("/refresh")
@router.post("/refresh", dependencies=[audited("Compliance refresh (all assets)")])
def refresh_everything(
db: Session = Depends(get_db),
current_user: User = Depends(RequireEditor),
+3 -3
View File
@@ -16,7 +16,7 @@ import time
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from app.auth.dependencies import RequireAdmin, RequireEditor
from app.auth.dependencies import RequireAdmin, RequireEditor, audited
from app.database import SessionLocal, get_db
from app.models.user import User
from app.services import dockhand_service
@@ -32,7 +32,7 @@ _DH_SYNC: dict = {"running": False, "result": None, "error": None,
"started_at": None, "finished_at": None}
@router.post("/test")
@router.post("/test", dependencies=[audited("Dockhand connection test")])
def test_dockhand(
db: Session = Depends(get_db),
current_user: User = Depends(RequireAdmin),
@@ -61,7 +61,7 @@ def _run_dockhand_sync_threaded() -> None:
_DH_SYNC["finished_at"] = time.time()
@router.post("/sync", status_code=202)
@router.post("/sync", status_code=202, dependencies=[audited("Dockhand sync")])
def sync_dockhand(
db: Session = Depends(get_db),
current_user: User = Depends(RequireEditor),
+23 -1
View File
@@ -12,6 +12,8 @@ from app.models.user import User, UserRole
from app.models.group import Group, user_groups
from app.models.setting import Setting
from app.auth.dependencies import get_current_user, RequireAdmin
from app.models.audit_log import AuditEventType
from app.services.audit_trail import record_change
router = APIRouter(prefix="/api/v1/groups", tags=["Groups"])
@@ -38,6 +40,17 @@ class GroupResponse(BaseModel):
class Config:
from_attributes = True
def _audit_state(group: Group) -> dict:
return {"name": group.name, "description": group.description,
"members": sorted(u.username for u in group.users),
"assets": sorted(a.hostname for a in group.assets)}
def _audit(db: Session, user: User, group: Group, verb: str, before: dict, after: dict) -> None:
record_change(db, user.id, AuditEventType.CONFIG_CHANGE, "group", group.id,
f"Group {verb}: {group.name}", before, after)
# --- Endpoints ---
@router.get("", response_model=List[GroupResponse])
@@ -81,6 +94,7 @@ def create_group(
users = db.query(User).filter(User.id.in_(group_in.user_ids)).all()
group.users = users
_audit(db, current_user, group, "created", {}, _audit_state(group))
db.commit()
db.refresh(group)
@@ -104,6 +118,7 @@ def update_group(
group = db.query(Group).filter(Group.id == group_id).first()
if not group:
raise HTTPException(status_code=404, detail="Group not found")
before = _audit_state(group)
if group_in.name:
existing = db.query(Group).filter(Group.name == group_in.name, Group.id != group_id).first()
@@ -118,6 +133,7 @@ def update_group(
users = db.query(User).filter(User.id.in_(group_in.user_ids)).all()
group.users = users
_audit(db, current_user, group, "updated", before, _audit_state(group))
db.commit()
db.refresh(group)
@@ -141,6 +157,8 @@ def delete_group(
if not group:
raise HTTPException(status_code=404, detail="Group not found")
_audit(db, current_user, group, "deleted", _audit_state(group), {})
# Clear M2M relationships first (user_groups and asset_groups tables)
group.users = []
group.assets = []
@@ -178,12 +196,16 @@ def set_default_group(
raise HTTPException(status_code=404, detail="Group not found")
setting = db.query(Setting).filter(Setting.key == "default_group_id").first()
record_change(db, current_user.id, AuditEventType.CONFIG_CHANGE, "setting", "default_group_id",
"Default group changed",
{"default_group_id": setting.value if setting else None},
{"default_group_id": None if group_id is None else str(group_id)})
if group_id is None:
# Clear the default group
if setting:
db.delete(setting)
db.commit()
db.commit()
return {"message": "Default group cleared"}
if not setting:
+3 -3
View File
@@ -16,7 +16,7 @@ import time
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from app.auth.dependencies import RequireAdmin, RequireEditor
from app.auth.dependencies import RequireAdmin, RequireEditor, audited
from app.services.sync_run_service import record_sync_run
from app.database import get_db, SessionLocal
from app.models.user import User
@@ -35,7 +35,7 @@ _IGEL_SYNC: dict = {"running": False, "result": None, "error": None,
"started_at": None, "finished_at": None}
@router.post("/test")
@router.post("/test", dependencies=[audited("IGEL connection test")])
def test_igel(
db: Session = Depends(get_db),
current_user: User = Depends(RequireAdmin),
@@ -79,7 +79,7 @@ def _run_igel_sync_threaded() -> None:
_IGEL_SYNC["finished_at"] = time.time()
@router.post("/sync", status_code=202)
@router.post("/sync", status_code=202, dependencies=[audited("IGEL sync")])
def sync_igel(
db: Session = Depends(get_db),
current_user: User = Depends(RequireEditor),
+3 -3
View File
@@ -18,7 +18,7 @@ from typing import Optional
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from app.auth.dependencies import RequireAdmin, RequireEditor
from app.auth.dependencies import RequireAdmin, RequireEditor, audited
from app.services.sync_run_service import record_sync_run
from app.database import get_db, SessionLocal
from app.models.user import User
@@ -35,7 +35,7 @@ _INTUNE_SYNC: dict = {"running": False, "result": None, "error": None,
"started_at": None, "finished_at": None}
@router.post("/test")
@router.post("/test", dependencies=[audited("Intune connection test")])
def test_intune(
db: Session = Depends(get_db),
current_user: User = Depends(RequireAdmin),
@@ -71,7 +71,7 @@ def _run_intune_sync_threaded() -> None:
_INTUNE_SYNC["finished_at"] = time.time()
@router.post("/sync", status_code=202)
@router.post("/sync", status_code=202, dependencies=[audited("Intune sync")])
def sync_intune(
db: Session = Depends(get_db),
current_user: User = Depends(RequireEditor),
+7 -6
View File
@@ -16,7 +16,8 @@ from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from app.auth.dependencies import RequireAdmin, RequireEditor
from app.auth.dependencies import RequireAdmin, RequireEditor, audited
from app.models.audit_log import AuditEventType
from app.services.sync_run_service import fail_if_source_broken, record_sync_run
from app.database import get_db, job_slot
from app.integrations.nessus_client import (
@@ -65,7 +66,7 @@ class NessusScanImportRequest(BaseModel):
# ---------- endpoints ----------
@router.post("/test")
@router.post("/test", dependencies=[audited("Nessus connection test")])
def test_nessus_connection(
db: Session = Depends(get_db),
current_user: User = Depends(RequireAdmin),
@@ -138,7 +139,7 @@ def list_nessus_scans(
raise HTTPException(502, f"Nessus API error: {e}")
@router.post("/sync")
@router.post("/sync", dependencies=[audited("Nessus sync")])
# Sync def → worker threadpool (off the event loop) so a long Nessus
# import doesn't freeze the web GUI.
def trigger_nessus_sync(
@@ -185,7 +186,7 @@ def trigger_nessus_sync(
raise HTTPException(500, f"Nessus sync failed: {e}")
@router.post("/scan-host")
@router.post("/scan-host", dependencies=[audited("Nessus targeted scan", body_fields=("asset_id", "scan_id"))])
def nessus_scan_host(
payload: NessusScanHostRequest,
db: Session = Depends(get_db),
@@ -295,7 +296,7 @@ def nessus_scan_host(
}
@router.post("/scan-and-import")
@router.post("/scan-and-import", dependencies=[audited("Nessus scan and import", body_fields=("asset_id", "scan_id"))])
def nessus_scan_and_import(
payload: NessusScanImportRequest,
db: Session = Depends(get_db),
@@ -332,7 +333,7 @@ def nessus_scan_and_import_status(
return job
@router.post("/cleanup-pseudo-cves")
@router.post("/cleanup-pseudo-cves", dependencies=[audited("Nessus pseudo-CVE cleanup", event_type=AuditEventType.VULNERABILITY_UPDATED)])
def cleanup_pseudo_cves(
db: Session = Depends(get_db),
current_user: User = Depends(RequireAdmin),
+3 -3
View File
@@ -17,7 +17,7 @@ import time
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from app.auth.dependencies import RequireAdmin, RequireEditor
from app.auth.dependencies import RequireAdmin, RequireEditor, audited
from app.services.sync_run_service import record_sync_run
from app.database import get_db, SessionLocal
from app.models.user import User
@@ -36,7 +36,7 @@ _ND_SYNC: dict = {"running": False, "result": None, "error": None,
"started_at": None, "finished_at": None}
@router.post("/test")
@router.post("/test", dependencies=[audited("Netdisco connection test")])
def test_netdisco(
db: Session = Depends(get_db),
current_user: User = Depends(RequireAdmin),
@@ -72,7 +72,7 @@ def _run_netdisco_sync_threaded() -> None:
_ND_SYNC["finished_at"] = time.time()
@router.post("/sync", status_code=202)
@router.post("/sync", status_code=202, dependencies=[audited("Netdisco sync")])
def sync_netdisco(
db: Session = Depends(get_db),
current_user: User = Depends(RequireEditor),
+3 -3
View File
@@ -15,7 +15,7 @@ from app.models.user import User
from app.models.notification_log import NotificationLog, NotificationType, NotificationStatus
from app.models.vulnerability import Vulnerability
from app.models.asset import Asset
from app.auth.dependencies import get_current_user, RequireAdmin
from app.auth.dependencies import get_current_user, RequireAdmin, audited
from app.services.email_service import send_email, get_smtp_config
router = APIRouter(prefix="/api/v1/notifications", tags=["Notifications"])
@@ -111,7 +111,7 @@ def list_notification_logs(
return results
@router.post("/test")
@router.post("/test", dependencies=[audited("Test email", body_fields=("to_email", "smtp_config.host"))])
# Sync def → threadpool; the blocking SMTP send won't stall the event loop.
def send_test_email(
test_data: TestEmailRequest,
@@ -145,7 +145,7 @@ def send_test_email(
raise HTTPException(status_code=500, detail=f"Email sending failed: {message}")
@router.post("/notify-critical")
@router.post("/notify-critical", dependencies=[audited("Critical vulnerability notification run")])
# Sync def → Starlette runs it in a worker threadpool (off the event loop), so
# the blocking SMTP send loop over thousands of CVEs doesn't freeze the web GUI.
def notify_unnotified_critical_vulnerabilities(
+21 -1
View File
@@ -11,9 +11,24 @@ from app.database import get_db
from app.models.policy import Policy, PolicyStatus
from app.models.user import User
from app.auth.dependencies import get_current_user, RequireAdmin, RequireEditor
from app.models.audit_log import AuditEventType
from app.services.audit_trail import record_change
router = APIRouter(prefix="/api/v1/policies", tags=["Policies"])
_AUDITED = ("name", "description", "category", "status",
"critical_sla_days", "high_sla_days", "medium_sla_days", "low_sla_days")
def _audit(db: Session, user: User, policy: Policy, verb: str, before: dict, after: dict) -> None:
record_change(db, user.id, AuditEventType.CONFIG_CHANGE, "policy", policy.id,
f"Policy {verb}: {policy.name}", before, after)
def _audit_state(policy: Policy) -> dict:
return {f: getattr(policy, f) for f in _AUDITED}
# --- Schemas ---
class PolicyBase(BaseModel):
name: str
@@ -70,6 +85,8 @@ def create_policy(
"""Create a new policy"""
policy = Policy(**policy_data.model_dump())
db.add(policy)
db.flush()
_audit(db, current_user, policy, "created", {}, _audit_state(policy))
db.commit()
db.refresh(policy)
return policy
@@ -86,8 +103,10 @@ def update_policy(
if not policy:
raise HTTPException(status_code=404, detail="Policy not found")
before = _audit_state(policy)
for field, value in policy_data.dict(exclude_unset=True).items():
setattr(policy, field, value)
_audit(db, current_user, policy, "updated", before, _audit_state(policy))
db.commit()
db.refresh(policy)
@@ -103,7 +122,8 @@ def delete_policy(
policy = db.query(Policy).filter(Policy.id == policy_id).first()
if not policy:
raise HTTPException(status_code=404, detail="Policy not found")
_audit(db, current_user, policy, "deleted", _audit_state(policy), {})
db.delete(policy)
db.commit()
return None
+26 -3
View File
@@ -13,7 +13,9 @@ from app.models.scan import Scan, ScanType, ScanStatus
from app.models.scan_schedule import ScanSchedule, ScheduleInterval
from app.models.asset import Asset
from app.models.user import User
from app.auth.dependencies import get_current_user, RequireEditor
from app.auth.dependencies import get_current_user, RequireEditor, audited
from app.models.audit_log import AuditEventType
from app.services.audit_trail import record_action, record_change
from app.routers.vulnerabilities import (
sync_agent_vulnerabilities, reconcile_empty_agents,
)
@@ -219,6 +221,9 @@ def clear_scans(
raise HTTPException(status_code=400, detail="Provide scan_ids, status_filter, or set all=true")
count = query.delete(synchronize_session=False)
record_action(db, current_user.id, AuditEventType.CONFIG_CHANGE,
f"Scan history cleared: {count} scans", resource_type="scan",
details=body.model_dump(exclude_defaults=True))
db.commit()
return {"deleted": count}
@@ -228,7 +233,7 @@ import json
from app.integrations.wazuh_client import WazuhClient, WazuhAPIError
from app.models.setting import Setting
@router.post("/autoscan", response_model=dict)
@router.post("/autoscan", response_model=dict, dependencies=[audited("Wazuh autoscan (all assets)")])
def trigger_autoscan(
db: Session = Depends(get_db),
current_user: User = Depends(RequireEditor)
@@ -357,6 +362,17 @@ INTERVAL_DELTA_MAP = {
ScheduleInterval.WEEKLY: timedelta(weeks=1),
}
def _audit_schedule(db: Session, user: User, schedule: ScanSchedule, verb: str,
before: dict, after: dict) -> None:
record_change(db, user.id, AuditEventType.CONFIG_CHANGE, "scan_schedule", schedule.id,
f"Scan schedule {verb}: {schedule.name}", before, after)
def _schedule_state(schedule: ScanSchedule) -> dict:
return {f: getattr(schedule, f)
for f in ("name", "interval", "cron_expression", "scanner_type", "enabled")}
@router.get("/schedules", response_model=List[ScheduleResponse])
def list_schedules(
db: Session = Depends(get_db),
@@ -384,6 +400,8 @@ def create_schedule(
next_run=datetime.now() + INTERVAL_DELTA_MAP.get(data.interval, timedelta(days=1))
)
db.add(schedule)
db.flush()
_audit_schedule(db, current_user, schedule, "created", {}, _schedule_state(schedule))
db.commit()
db.refresh(schedule)
return schedule
@@ -400,6 +418,7 @@ def update_schedule(
schedule = db.query(ScanSchedule).filter(ScanSchedule.id == schedule_id).first()
if not schedule:
raise HTTPException(status_code=404, detail="Schedule not found")
before = _schedule_state(schedule)
schedule.name = data.name
schedule.interval = data.interval
@@ -413,7 +432,8 @@ def update_schedule(
schedule.next_run = None
else:
schedule.next_run = datetime.now() + INTERVAL_DELTA_MAP.get(data.interval, timedelta(days=1))
_audit_schedule(db, current_user, schedule, "updated", before, _schedule_state(schedule))
db.commit()
db.refresh(schedule)
return schedule
@@ -430,6 +450,7 @@ def toggle_schedule(
if not schedule:
raise HTTPException(status_code=404, detail="Schedule not found")
before = _schedule_state(schedule)
schedule.enabled = not schedule.enabled
if schedule.enabled:
if schedule.interval == ScheduleInterval.CRON and schedule.cron_expression:
@@ -438,6 +459,7 @@ def toggle_schedule(
schedule.next_run = datetime.now() + INTERVAL_DELTA_MAP.get(schedule.interval, timedelta(days=1))
else:
schedule.next_run = None
_audit_schedule(db, current_user, schedule, "updated", before, _schedule_state(schedule))
db.commit()
db.refresh(schedule)
return schedule
@@ -454,6 +476,7 @@ def delete_schedule(
if not schedule:
raise HTTPException(status_code=404, detail="Schedule not found")
_audit_schedule(db, current_user, schedule, "deleted", _schedule_state(schedule), {})
db.delete(schedule)
db.commit()
+3 -3
View File
@@ -12,7 +12,7 @@ from app.database import get_db
from app.models.audit_log import AuditEventType, AuditLog
from app.models.setting import Setting
from app.models.user import User
from app.auth.dependencies import RequireAdmin
from app.auth.dependencies import RequireAdmin, audited
from app.auth.setting_crypto import (
PROTECTED_SETTING_KEYS,
decrypt_value,
@@ -31,7 +31,7 @@ class SyslogTestRequest(BaseModel):
facility: int = 16
@router.post("/syslog/test")
@router.post("/syslog/test", dependencies=[audited("Syslog test message", body_fields=("host", "port", "protocol"))])
def test_syslog(
payload: SyslogTestRequest,
current_user: User = Depends(RequireAdmin),
@@ -240,7 +240,7 @@ class AIModelsRequest(BaseModel):
base_url: Optional[str] = None
@router.post("/ai/models")
@router.post("/ai/models", dependencies=[audited("AI model list fetched", body_fields=("provider", "base_url"))])
async def list_ai_models(
body: AIModelsRequest,
db: Session = Depends(get_db),
+4 -4
View File
@@ -17,7 +17,7 @@ import time
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from app.auth.dependencies import RequireAdmin, RequireEditor
from app.auth.dependencies import RequireAdmin, RequireEditor, audited
from app.services.sync_run_service import record_sync_run
from app.database import get_db, SessionLocal
from app.models.user import User
@@ -36,7 +36,7 @@ _VCENTER_SYNC: dict = {"running": False, "result": None, "error": None,
"started_at": None, "finished_at": None}
@router.post("/test")
@router.post("/test", dependencies=[audited("vCenter connection test")])
def test_vcenter(
db: Session = Depends(get_db),
current_user: User = Depends(RequireAdmin),
@@ -72,7 +72,7 @@ def _run_vcenter_sync_threaded() -> None:
_VCENTER_SYNC["finished_at"] = time.time()
@router.post("/sync", status_code=202)
@router.post("/sync", status_code=202, dependencies=[audited("vCenter sync")])
def sync_vcenter(
db: Session = Depends(get_db),
current_user: User = Depends(RequireEditor),
@@ -103,7 +103,7 @@ def sync_vcenter_status(current_user: User = Depends(RequireEditor)):
"error": s["error"], "finished_at": s["finished_at"]}
@router.post("/catalog/refresh")
@router.post("/catalog/refresh", dependencies=[audited("vCenter build catalog refresh")])
def refresh_build_catalog(
db: Session = Depends(get_db),
current_user: User = Depends(RequireAdmin),
+60 -27
View File
@@ -25,7 +25,8 @@ from app.models.vulnerability import Vulnerability, VulnerabilitySeverity, Vulne
from app.models.asset import Asset, AssetStatus, ContainerPlacement
from app.models.ai_analysis import AIAnalysis
from app.models.audit_log import AuditLog, AuditEventType
from app.auth.dependencies import get_current_user, RequireEditor
from app.auth.dependencies import get_current_user, RequireEditor, audited
from app.services.audit_trail import record_change
from app.integrations.wazuh_client import get_wazuh_client, WazuhClient
from app.models.setting import Setting
from app.integrations.infomaniak_ai_client import InfomaniakAIClient, get_infomaniak_ai_client, AIAnalysisService
@@ -250,6 +251,24 @@ def _auto_reason(new_status, user_name: str) -> str:
return f"Manually set to {label} by {user_name} on {ts}"
def _assignment(vuln: Vulnerability) -> dict:
return {"assigned_user": vuln.assigned_user.username if vuln.assigned_user else None,
"assigned_group": vuln.group.name if vuln.group else None}
def _deferral(vuln: Vulnerability) -> dict:
return {"deferred_until": vuln.deferred_until, "defer_reason": vuln.defer_reason,
"notification_suppressed": vuln.notification_suppressed}
def _audit_vuln_update(db: Session, user: User, vuln: Vulnerability, before: dict, after: dict) -> None:
"""Changes to a finding other than its status (log_vulnerability_change)."""
host = vuln.asset.hostname if vuln.asset else None
record_change(db, user.id, AuditEventType.VULNERABILITY_UPDATED, "vulnerability", vuln.id,
f"Vulnerability {vuln.cve_id}" + (f" on {host}" if host else "") + " updated",
before, after)
def log_vulnerability_change(
db: Session,
user_id: Optional[int],
@@ -1721,7 +1740,7 @@ def _run_date_backfill_threaded() -> dict:
_DATE_BACKFILL_RUNNING = False
@router.post("/dates/backfill", status_code=202)
@router.post("/dates/backfill", status_code=202, dependencies=[audited("CVE date backfill")])
def backfill_cve_dates(
current_user: User = Depends(RequireEditor),
):
@@ -1747,7 +1766,7 @@ def backfill_cve_dates(
"(watch the backend logs)."}
@router.post("/enrich/kev/refresh")
@router.post("/enrich/kev/refresh", dependencies=[audited("CISA KEV refresh")])
def refresh_kev_catalog(
db: Session = Depends(get_db),
current_user: User = Depends(RequireEditor)
@@ -1783,8 +1802,12 @@ def assign_vulnerability(
if not group:
raise HTTPException(status_code=404, detail="Group not found")
before = _assignment(vuln)
vuln.assigned_user_id = assign_data.user_id
vuln.assigned_group_id = assign_data.group_id
db.flush()
db.expire(vuln, ["assigned_user", "group"]) # reload them from the new ids
_audit_vuln_update(db, current_user, vuln, before, _assignment(vuln))
db.commit()
db.refresh(vuln)
return _build_vuln_response(vuln)
@@ -1802,15 +1825,17 @@ def defer_vulnerability(
if not vuln:
raise HTTPException(status_code=404, detail="Vulnerability not found")
old_status = vuln.status
old_status, before = vuln.status, _deferral(vuln)
vuln.status = VulnerabilityStatus.deferred
vuln.deferred_until = defer_data.deferred_until
vuln.defer_reason = defer_data.reason
vuln.notification_suppressed = True
_audit_vuln_update(db, current_user, vuln, before, _deferral(vuln))
db.commit()
db.refresh(vuln)
log_vulnerability_change(db, current_user.id, vuln_id, old_status, vuln.status)
log_vulnerability_change(db, current_user.id, vuln_id, old_status, vuln.status,
reason=defer_data.reason, cve_id=vuln.cve_id)
return _build_vuln_response(vuln)
@@ -1825,15 +1850,16 @@ def undefer_vulnerability(
if not vuln:
raise HTTPException(status_code=404, detail="Vulnerability nicht gefunden")
old_status = vuln.status
old_status, before = vuln.status, _deferral(vuln)
vuln.status = VulnerabilityStatus.open
vuln.deferred_until = None
vuln.defer_reason = None
vuln.notification_suppressed = False
_audit_vuln_update(db, current_user, vuln, before, _deferral(vuln))
db.commit()
db.refresh(vuln)
log_vulnerability_change(db, current_user.id, vuln_id, old_status, vuln.status)
log_vulnerability_change(db, current_user.id, vuln_id, old_status, vuln.status, cve_id=vuln.cve_id)
return _build_vuln_response(vuln)
@@ -1848,7 +1874,10 @@ def toggle_notification_suppression(
if not vuln:
raise HTTPException(status_code=404, detail="Vulnerability nicht gefunden")
before = {"notification_suppressed": vuln.notification_suppressed}
vuln.notification_suppressed = not vuln.notification_suppressed
_audit_vuln_update(db, current_user, vuln, before,
{"notification_suppressed": vuln.notification_suppressed})
db.commit()
db.refresh(vuln)
return _build_vuln_response(vuln)
@@ -1874,15 +1903,17 @@ def mark_false_positive(
if not vuln:
raise HTTPException(status_code=404, detail="Vulnerability not found")
old_status = vuln.status
old_status, before = vuln.status, _deferral(vuln)
vuln.status = VulnerabilityStatus.false_positive
vuln.notification_suppressed = True
if payload.reason:
vuln.defer_reason = payload.reason # reuse existing field for context
_audit_vuln_update(db, current_user, vuln, before, _deferral(vuln))
db.commit()
db.refresh(vuln)
log_vulnerability_change(db, current_user.id, vuln_id, old_status, vuln.status)
log_vulnerability_change(db, current_user.id, vuln_id, old_status, vuln.status,
reason=payload.reason, cve_id=vuln.cve_id)
return _build_vuln_response(vuln)
@@ -1899,18 +1930,20 @@ def unmark_false_positive(
if vuln.status != VulnerabilityStatus.false_positive:
raise HTTPException(status_code=400, detail="Not currently marked as false positive")
old_status = vuln.status
old_status, before = vuln.status, _deferral(vuln)
vuln.status = VulnerabilityStatus.open
vuln.notification_suppressed = False
vuln.defer_reason = None
_audit_vuln_update(db, current_user, vuln, before, _deferral(vuln))
db.commit()
db.refresh(vuln)
log_vulnerability_change(db, current_user.id, vuln_id, old_status, vuln.status)
log_vulnerability_change(db, current_user.id, vuln_id, old_status, vuln.status, cve_id=vuln.cve_id)
return _build_vuln_response(vuln)
@router.post("/sync/container")
@router.post("/sync/container", dependencies=[audited(
"Container image report import", body_fields=("ArtifactName", "source.target.userInput"))])
def sync_container_image_report(
report: dict = Body(..., description="Trivy `--format json` or Grype `-o json` output of ONE image"),
min_severity: str = Query("high", description="none|low|medium|high|critical — below is not imported"),
@@ -1926,7 +1959,7 @@ def sync_container_image_report(
raise HTTPException(status_code=422, detail=str(e))
@router.post("/sync/wazuh")
@router.post("/sync/wazuh", dependencies=[audited("Wazuh vulnerability sync")])
def sync_vulnerabilities_from_wazuh(
db: Session = Depends(get_db),
current_user: User = Depends(RequireEditor)
@@ -2974,7 +3007,7 @@ def override_from_vulnrichment(
return result
@router.post("/override/vulnrichment/start")
@router.post("/override/vulnrichment/start", dependencies=[audited("CISA Vulnrichment CVSS correction", event_type=AuditEventType.VULNERABILITY_UPDATED)])
def start_vulnrichment_correction_job(
cve_ids: Optional[List[str]] = Query(None, description="CVE-IDs zum Korrigieren (leer = alle)"),
asset_ids: Optional[List[int]] = Query(None, description="Asset-IDs (leer = alle)"),
@@ -3038,7 +3071,7 @@ def list_vulnrichment_correction_jobs(
return list_override_jobs(limit=limit)
@router.post("/exploit-intel/refresh")
@router.post("/exploit-intel/refresh", dependencies=[audited("Exploit intel refresh")])
# Sync def → FastAPI runs it in a worker threadpool, off the event loop,
# so the catalog downloads don't freeze the web GUI.
@job_slot("exploit_intel")
@@ -3088,7 +3121,7 @@ def _eol_job_runner(asset_id: Optional[int], user):
_EOL_JOB["finished_at"] = _time.time()
@router.post("/eol-check/start", status_code=202)
@router.post("/eol-check/start", status_code=202, dependencies=[audited("End-of-life check")])
def start_eol_check_job(
asset_id: Optional[int] = Query(None, description="Scope to one asset; omit for all"),
current_user: User = Depends(RequireEditor),
@@ -3123,7 +3156,7 @@ def get_eol_check_status(current_user: User = Depends(get_current_user)):
}
@router.post("/eol-check")
@router.post("/eol-check", dependencies=[audited("End-of-life check")])
# Sync def → runs in a worker threadpool (off the event loop) so the
# per-asset endoflife.date / MS-lifecycle work doesn't freeze the web GUI.
@job_slot("eol")
@@ -3265,7 +3298,7 @@ def run_eol_check(
return stats
@router.post("/m365-check")
@router.post("/m365-check", dependencies=[audited("Microsoft 365 check")])
# Sync def → runs in a worker threadpool (off the event loop) so the
# cvelistV5/MSRC metadata work doesn't freeze the web GUI.
@job_slot("m365")
@@ -3356,7 +3389,7 @@ def _appscan_job_runner(asset_id: Optional[int], rebuild_index: bool):
_APPSCAN_JOB["finished_at"] = _time.time()
@router.post("/app-cve-scan/start", status_code=202)
@router.post("/app-cve-scan/start", status_code=202, dependencies=[audited("Application CVE scan")])
def start_app_cve_scan_job(
asset_id: Optional[int] = Query(None, description="Scope to one asset; omit for all"),
rebuild_index: bool = Query(False, description="Rebuild the CVE indexes first, as the 01:30 job does (downloads the ~557 MB cvelistV5 ZIP)"),
@@ -3395,7 +3428,7 @@ def get_app_cve_scan_status(current_user: User = Depends(get_current_user)):
}
@router.post("/app-cve-scan")
@router.post("/app-cve-scan", dependencies=[audited("Application CVE scan")])
# Sync def → worker threadpool; OSV/NVD-CPE lookups are blocking I/O and
# must not freeze the web GUI. Kept for API clients; the GUI uses start/status.
@job_slot("app_cve_scan", "vuln_index")
@@ -3454,7 +3487,7 @@ def _msrc_scan_job_runner(asset_id: Optional[int], rebuild_index: bool):
_MSRC_SCAN_JOB["finished_at"] = _time.time()
@router.post("/msrc-scan/start", status_code=202)
@router.post("/msrc-scan/start", status_code=202, dependencies=[audited("MSRC scan")])
def start_msrc_scan_job(
asset_id: Optional[int] = Query(None, description="Scope to one asset; omit for all Windows Server assets"),
rebuild_index: bool = Query(True, description="Re-pull the MSRC CVRF documents first"),
@@ -3495,7 +3528,7 @@ def get_msrc_scan_status(current_user: User = Depends(get_current_user)):
}
@router.post("/msrc-scan")
@router.post("/msrc-scan", dependencies=[audited("MSRC scan")])
# Sync def → worker threadpool; the CVRF fetch is blocking I/O.
@job_slot("msrc_scan", "vuln_index")
def run_msrc_scan_endpoint(
@@ -3521,7 +3554,7 @@ def run_msrc_scan_endpoint(
raise HTTPException(502, f"MSRC scan failed: {e}")
@router.post("/suppress-false-positives")
@router.post("/suppress-false-positives", dependencies=[audited("False-positive suppression run", event_type=AuditEventType.VULNERABILITY_UPDATED)])
# Sync def → worker threadpool; reads the cvelistV5 ZIP (blocking I/O).
def suppress_false_positives_endpoint(
asset_id: Optional[int] = Query(None, description="Scope to one asset; omit for all"),
@@ -3551,7 +3584,7 @@ def ai_remediation_status(
return {"enabled": ai_service.is_enabled(db)}
@router.post("/{vuln_id}/ai-remediation")
@router.post("/{vuln_id}/ai-remediation", dependencies=[audited("AI remediation requested", event_type=AuditEventType.AI_ANALYSIS_REQUESTED, resource_type="vulnerability")])
async def ai_remediation(
vuln_id: int,
db: Session = Depends(get_db),
@@ -3789,7 +3822,7 @@ def _run_msrc_refresh_threaded(months_back: Optional[int] = None) -> None:
_MSRC_REFRESH["finished_at"] = _time.time()
@router.post("/msrc/refresh", status_code=202)
@router.post("/msrc/refresh", status_code=202, dependencies=[audited("MSRC catalogue refresh")])
def refresh_msrc_endpoint(
months_back: Optional[int] = Query(None, description="How many recent monthly MSRC docs to ingest"),
current_user: User = Depends(RequireEditor),
@@ -3818,7 +3851,7 @@ def msrc_refresh_status(current_user: User = Depends(RequireEditor)):
"error": s["error"], "finished_at": s["finished_at"]}
@router.post("/recompute-scores")
@router.post("/recompute-scores", dependencies=[audited("Priority score recompute")])
def recompute_priority_scores(
db: Session = Depends(get_db),
current_user: User = Depends(RequireEditor),
@@ -3841,7 +3874,7 @@ def recompute_priority_scores(
return {"updated": updated}
@router.post("/canonicalize-cve-metadata")
@router.post("/canonicalize-cve-metadata", dependencies=[audited("CVE metadata canonicalisation", event_type=AuditEventType.VULNERABILITY_UPDATED)])
def canonicalize_cve_metadata(
db: Session = Depends(get_db),
current_user: User = Depends(RequireEditor),
+3 -3
View File
@@ -18,7 +18,7 @@ import time
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from app.auth.dependencies import RequireAdmin, RequireEditor
from app.auth.dependencies import RequireAdmin, RequireEditor, audited
from app.services.sync_run_service import record_sync_run
from app.database import get_db, SessionLocal
from app.models.user import User
@@ -36,7 +36,7 @@ _XS_SYNC: dict = {"running": False, "result": None, "error": None,
"started_at": None, "finished_at": None}
@router.post("/test")
@router.post("/test", dependencies=[audited("XenServer connection test")])
def test_xenserver(
db: Session = Depends(get_db),
current_user: User = Depends(RequireAdmin),
@@ -73,7 +73,7 @@ def _run_xenserver_sync_threaded() -> None:
_XS_SYNC["finished_at"] = time.time()
@router.post("/sync", status_code=202)
@router.post("/sync", status_code=202, dependencies=[audited("XenServer sync")])
def sync_xenserver(
db: Session = Depends(get_db),
current_user: User = Depends(RequireEditor),
+73
View File
@@ -0,0 +1,73 @@
"""Audit rows for what a user did: one action, or one change naming every field
old → new. Username, client IP and user agent are stamped on insert
(audit_log._stamp_actor), so callers pass only the user id.
"""
import json
from datetime import datetime
from enum import Enum
from typing import Any, Optional
from sqlalchemy.orm import Session
from app.models.audit_log import AuditEventType, AuditLog
def _plain(v: Any) -> Any:
return v.value if isinstance(v, Enum) else v
def _shown(v: Any) -> str:
return "–" if v is None else str(v)
def record_change(
db: Session,
user_id: Optional[int],
event_type: AuditEventType,
resource_type: str,
resource_id: Any,
subject: str,
before: dict,
after: dict,
) -> bool:
"""One row naming every field that differs between `before` and `after`
(a create passes before={}, a delete after={}; empty fields of the one
side are left out). Nothing differs, no row."""
before = {k: _plain(v) for k, v in before.items() if after or v not in (None, "", [])}
after = {k: _plain(v) for k, v in after.items() if before or v not in (None, "", [])}
changed = [k for k in dict.fromkeys([*before, *after]) if before.get(k) != after.get(k)]
if not changed:
return False
shown = "; ".join(f"{k}: {_shown(before.get(k))} → {_shown(after.get(k))}" for k in changed)
db.add(AuditLog(
user_id=user_id,
event_type=event_type,
event_description=f"{subject} — {shown}"[:500],
resource_type=resource_type,
resource_id=None if resource_id is None else str(resource_id)[:50],
old_value=json.dumps({k: before[k] for k in changed if k in before}, default=str),
new_value=json.dumps({k: after[k] for k in changed if k in after}, default=str),
timestamp=datetime.now(),
))
return True
def record_action(
db: Session,
user_id: Optional[int],
event_type: AuditEventType,
description: str,
resource_type: Optional[str] = None,
resource_id: Any = None,
details: Optional[dict] = None,
) -> None:
"""One row for an action that changes no single record (sync, scan, test)."""
db.add(AuditLog(
user_id=user_id,
event_type=event_type,
event_description=description[:500],
resource_type=resource_type,
resource_id=None if resource_id is None else str(resource_id)[:50],
new_value=json.dumps(details, default=str) if details else None,
timestamp=datetime.now(),
))
+264
View File
@@ -0,0 +1,264 @@
"""Every user action leaves an audit row, with old → new where it changes
something — run: venv/bin/python -m pytest tests/test_audit_user_actions.py
Follow-up to tests/test_audit_who_what_where.py: groups, policies, scan
schedules, finding assignment/suppression and every sync/scan/test button
wrote no row at all, so "who did this" had no answer for them.
"""
import json
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
os.environ.setdefault("JWT_SECRET_KEY", "test-key-test-key-test-key-test-key")
from datetime import datetime # noqa: E402
import pytest # noqa: E402
from fastapi import Depends, FastAPI, HTTPException # noqa: E402
from fastapi.testclient import TestClient # noqa: E402
from sqlalchemy import create_engine # noqa: E402
from sqlalchemy.orm import sessionmaker # noqa: E402
from sqlalchemy.pool import StaticPool # noqa: E402
import app.models # noqa: F401,E402 (registers every mapper)
from app.database import get_db # noqa: E402
from app.auth.dependencies import RequireEditor, audited, capture_request_origin, get_current_user # noqa: E402
from app.models.asset import Asset # noqa: E402
from app.models.audit_log import AuditEventType, AuditLog # noqa: E402
from app.models.base import Base # noqa: E402
from app.models.group import Group # noqa: E402
from app.models.user import User, UserRole # noqa: E402
from app.models.vulnerability import Vulnerability, VulnerabilitySeverity # noqa: E402
def _db():
engine = create_engine("sqlite://", poolclass=StaticPool,
connect_args={"check_same_thread": False})
Base.metadata.create_all(engine)
db = sessionmaker(bind=engine)()
admin = User(username="alice", email="alice@example.test", password_hash="x",
role=UserRole.ADMIN)
db.add(admin)
db.commit()
return db, admin
def _rows(db):
return db.query(AuditLog).order_by(AuditLog.id).all()
# --- action buttons -------------------------------------------------------
def _button_app(db, user, fail=False):
api = FastAPI(dependencies=[Depends(capture_request_origin)])
@api.post("/sync/{asset_id}", dependencies=[audited("Wazuh sync", resource_type="asset")])
def sync(asset_id: int, current_user: User = Depends(RequireEditor)):
if fail:
raise HTTPException(502, "Wazuh unreachable")
return {"ok": True}
api.dependency_overrides[get_db] = lambda: db
api.dependency_overrides[get_current_user] = lambda: user
return TestClient(api)
def test_button_writes_one_row_naming_who_and_what():
db, admin = _db()
assert _button_app(db, admin).post("/sync/7").status_code == 200
(row,) = _rows(db)
assert (row.event_type, row.event_description, row.resource_type, row.resource_id, row.username,
row.ip_address) == \
(AuditEventType.SCAN_TRIGGERED, "Wazuh sync", "asset", "7", "alice", "testclient")
def test_failed_or_forbidden_button_writes_no_row():
db, admin = _db()
assert _button_app(db, admin, fail=True).post("/sync/7").status_code == 502
reader = User(username="carol", email="carol@example.test", password_hash="x",
role=UserRole.READONLY)
db.add(reader)
db.commit()
assert _button_app(db, reader).post("/sync/7").status_code == 403
assert _rows(db) == []
# --- records that change ----------------------------------------------------
def test_group_create_update_delete():
from app.routers.groups import GroupCreate, GroupUpdate, create_group, delete_group, update_group
db, admin = _db()
g = create_group(GroupCreate(name="Ops", user_ids=[admin.id]), db=db, current_user=admin)
update_group(g["id"], GroupUpdate(name="SecOps", user_ids=[]), db=db, current_user=admin)
update_group(g["id"], GroupUpdate(name="SecOps"), db=db, current_user=admin) # no change, no row
delete_group(g["id"], db=db, current_user=admin)
assert [r.event_description for r in _rows(db)] == [
"Group created: Ops — name: – → Ops; members: – → ['alice']",
"Group updated: SecOps — name: Ops → SecOps; members: ['alice'] → []",
"Group deleted: SecOps — name: SecOps → –",
]
assert {r.resource_type for r in _rows(db)} == {"group"}
def test_default_group_change():
from app.routers.groups import set_default_group
db, admin = _db()
db.add(Group(name="Ops"))
db.commit()
set_default_group(group_id=1, db=db, current_user=admin)
set_default_group(group_id=None, db=db, current_user=admin)
assert [r.event_description for r in _rows(db)] == [
"Default group changed — default_group_id: – → 1",
"Default group changed — default_group_id: 1 → –",
]
def test_policy_update_names_each_field():
from app.routers.policies import PolicyCreate, PolicyUpdate, create_policy, update_policy
db, admin = _db()
p = create_policy(PolicyCreate(name="Servers", category="infra"), db=db, current_user=admin)
update_policy(p.id, PolicyUpdate(critical_sla_days=1, name="Servers"), db=db, current_user=admin)
created, updated = _rows(db)
assert created.event_description.startswith("Policy created: Servers — name: – → Servers")
assert "status: – → active" in created.event_description
assert updated.event_description == "Policy updated: Servers — critical_sla_days: 2 → 1"
assert json.loads(updated.old_value) == {"critical_sla_days": 2}
def test_schedule_toggle_and_delete():
from app.models.scan_schedule import ScheduleInterval
from app.routers.scans import ScheduleCreateRequest, create_schedule, delete_schedule, toggle_schedule
db, admin = _db()
s = create_schedule(ScheduleCreateRequest(name="nightly", interval=ScheduleInterval.DAILY),
db=db, current_user=admin)
toggle_schedule(s.id, db=db, current_user=admin)
delete_schedule(s.id, db=db, current_user=admin)
descs = [r.event_description for r in _rows(db)]
assert descs[0].startswith("Scan schedule created: nightly — name: – → nightly; interval: – → daily")
assert descs[1] == "Scan schedule updated: nightly — enabled: True → False"
assert descs[2].startswith("Scan schedule deleted: nightly — name: nightly → –")
def _vuln(db):
asset = Asset(hostname="srv01")
db.add(asset)
db.flush()
v = Vulnerability(asset_id=asset.id, cve_id="CVE-2026-0001", severity=VulnerabilitySeverity.high,
detected_at=datetime.now())
db.add(v)
db.commit()
return v
def test_finding_assign_and_suppression():
from app.routers.vulnerabilities import (AssignRequest, assign_vulnerability,
toggle_notification_suppression)
db, admin = _db()
v = _vuln(db)
assign_vulnerability(v.id, AssignRequest(user_id=admin.id), db=db, current_user=admin)
toggle_notification_suppression(v.id, db=db, current_user=admin)
assert [r.event_description for r in _rows(db)] == [
"Vulnerability CVE-2026-0001 on srv01 updated — assigned_user: – → alice",
"Vulnerability CVE-2026-0001 on srv01 updated — notification_suppressed: False → True",
]
assert {(r.resource_type, r.resource_id) for r in _rows(db)} == {("vulnerability", str(v.id))}
def test_defer_and_false_positive_keep_the_reason():
from app.routers.vulnerabilities import (DeferRequest, FalsePositiveRequest, defer_vulnerability,
mark_false_positive)
db, admin = _db()
v = _vuln(db)
defer_vulnerability(v.id, DeferRequest(deferred_until=datetime(2026, 12, 31), reason="patch window"),
db=db, current_user=admin)
mark_false_positive(v.id, FalsePositiveRequest(reason="backported fix"), db=db, current_user=admin)
defer_fields, defer_status, fp_fields, fp_status = _rows(db)
assert defer_fields.event_description == (
"Vulnerability CVE-2026-0001 on srv01 updated — deferred_until: – → 2026-12-31 00:00:00; "
"defer_reason: – → patch window; notification_suppressed: False → True")
assert json.loads(defer_status.new_value)["reason"] == "patch window"
assert fp_fields.event_description.endswith("defer_reason: patch window → backported fix")
assert json.loads(fp_status.new_value)["reason"] == "backported fix"
def test_undefer_records_what_it_clears():
from app.routers.vulnerabilities import DeferRequest, defer_vulnerability, undefer_vulnerability
db, admin = _db()
v = _vuln(db)
defer_vulnerability(v.id, DeferRequest(deferred_until=datetime(2026, 12, 31), reason="patch window"),
db=db, current_user=admin)
undefer_vulnerability(v.id, db=db, current_user=admin)
fields, status = _rows(db)[2:]
assert "deferred_until: 2026-12-31 00:00:00 → –; defer_reason: patch window → –" in fields.event_description
assert status.event_description.startswith("Vulnerability CVE-2026-0001 status changed: deferred → open")
def test_asset_assign_diff():
from app.routers.assets import AssetAssignRequest, assign_asset
db, admin = _db()
asset = Asset(hostname="srv01")
db.add_all([asset, Group(name="Ops")])
db.commit()
assign_asset(asset.id, AssetAssignRequest(user_id=admin.id, group_id=1), db=db, current_user=admin)
(row,) = _rows(db)
assert row.event_description == "Asset updated: srv01 — assigned_user: – → alice; groups: [] → ['Ops']"
def test_user_email_change():
from starlette.requests import Request
from app.routers.auth import UserUpdateRequest, update_user
db, admin = _db()
req = Request({"type": "http", "headers": [], "client": ("10.0.0.9", 1)})
update_user(req, admin.id, UserUpdateRequest(email="a2@example.com"), current_user=admin, db=db)
(row,) = _rows(db)
assert row.event_description == "User updated: alice — email: alice@example.test → a2@example.com"
_ROUTERS = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "app", "routers")
# Session upkeep, not an action.
_NOT_ACTIONS = {"refresh_token"}
@pytest.mark.parametrize("name", sorted(f for f in os.listdir(_ROUTERS) if f.endswith(".py")))
def test_every_write_route_is_audited(name):
"""Inventory guard: a new POST/PUT/PATCH/DELETE route has to audit itself
(AuditLog, an audit helper, or the audited() dependency)."""
import ast
import re
src = open(os.path.join(_ROUTERS, name)).read()
missing = []
for node in ast.parse(src).body:
if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
continue
for d in node.decorator_list:
if (isinstance(d, ast.Call) and isinstance(d.func, ast.Attribute)
and d.func.attr in ("post", "put", "patch", "delete")):
text = ast.unparse(node) # decorators included, comments not
if not re.search(r"AuditLog\(|log_audit_event\(|\b_audit(_asset_update|_schedule|_vuln_update)?\(|log_vulnerability_change\(|"
r"record_change\(|record_action\(|audited\(|apply_status\(", text):
missing.append(node.name)
assert set(missing) - _NOT_ACTIONS == set()
def test_button_keeps_named_body_fields_never_the_rest():
db, admin = _db()
api = FastAPI()
@api.post("/test-mail", dependencies=[audited("Test email", body_fields=("to_email", "smtp_config.host"))])
def test_mail(body: dict, current_user: User = Depends(RequireEditor)):
return {"ok": True}
api.dependency_overrides[get_db] = lambda: db
api.dependency_overrides[get_current_user] = lambda: admin
TestClient(api).post("/test-mail?dry_run=true", json={
"to_email": "ops@example.test", "smtp_config": {"host": "mail.example.test", "password": "pw"}})
(row,) = _rows(db)
assert json.loads(row.new_value) == {"dry_run": "true", "to_email": "ops@example.test",
"smtp_config.host": "mail.example.test"}