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.
105 lines
3.9 KiB
Python
105 lines
3.9 KiB
Python
"""Netdisco integration HTTP endpoints.
|
|
|
|
- POST /api/v1/integrations/netdisco/test auth/connectivity probe
|
|
- POST /api/v1/integrations/netdisco/sync trigger inventory sync
|
|
- GET /api/v1/integrations/netdisco/sync/status poll target for the GUI
|
|
|
|
Config lives in the settings table under `netdisco_config` (encrypted JSON:
|
|
host, port, use_https, verify_ssl, api_key, username, password,
|
|
auto_create_assets).
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import threading
|
|
import time
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException
|
|
from sqlalchemy.orm import Session
|
|
|
|
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
|
|
from app.services.netdisco_service import (
|
|
_build_client, load_netdisco_config, run_netdisco_sync,
|
|
)
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
router = APIRouter(prefix="/api/v1/integrations/netdisco", tags=["Netdisco"])
|
|
|
|
# Last/current manual-sync state so the GUI can poll for completion (the sync
|
|
# itself is fire-and-forget 202). ponytail: module-level state assumes the
|
|
# single uvicorn worker we ship with; if you add --workers, move this to the DB.
|
|
_ND_SYNC: dict = {"running": False, "result": None, "error": None,
|
|
"started_at": None, "finished_at": None}
|
|
|
|
|
|
@router.post("/test", dependencies=[audited("Netdisco connection test")])
|
|
def test_netdisco(
|
|
db: Session = Depends(get_db),
|
|
current_user: User = Depends(RequireAdmin),
|
|
):
|
|
"""Verify the Netdisco connection: login (if any), then the inventory read."""
|
|
cfg = load_netdisco_config(db)
|
|
if not cfg:
|
|
raise HTTPException(400, "Netdisco is not configured (host missing).")
|
|
client = _build_client(cfg)
|
|
try:
|
|
return client.test_connection()
|
|
finally:
|
|
client.close()
|
|
|
|
|
|
def _run_netdisco_sync_threaded() -> None:
|
|
db = SessionLocal()
|
|
try:
|
|
with record_sync_run("netdisco", "manual") as run:
|
|
stats = run_netdisco_sync(db)
|
|
run.stats.update(stats)
|
|
result = {k: v for k, v in stats.items() if k != "errors"}
|
|
_ND_SYNC["result"] = result
|
|
_ND_SYNC["error"] = None
|
|
logger.info("Netdisco sync (manual) done: %s", result)
|
|
except Exception as e:
|
|
_ND_SYNC["result"] = None
|
|
_ND_SYNC["error"] = str(e)
|
|
logger.error("Netdisco sync (manual) failed: %s", e)
|
|
finally:
|
|
db.close()
|
|
_ND_SYNC["running"] = False
|
|
_ND_SYNC["finished_at"] = time.time()
|
|
|
|
|
|
@router.post("/sync", status_code=202, dependencies=[audited("Netdisco sync")])
|
|
def sync_netdisco(
|
|
db: Session = Depends(get_db),
|
|
current_user: User = Depends(RequireEditor),
|
|
):
|
|
"""Kick off a Netdisco inventory sync in the background (202).
|
|
|
|
Fire-and-forget so a large estate can't trip the reverse-proxy request
|
|
timeout — a campus Netdisco holds thousands of devices. Poll GET
|
|
/sync/status."""
|
|
if not load_netdisco_config(db):
|
|
raise HTTPException(400, "Netdisco is not configured.")
|
|
if _ND_SYNC["running"]:
|
|
return {"status": "already_running", "detail": "A Netdisco sync is already in progress."}
|
|
_ND_SYNC.update({"running": True, "result": None, "error": None,
|
|
"started_at": time.time(), "finished_at": None})
|
|
threading.Thread(target=_run_netdisco_sync_threaded, daemon=True).start()
|
|
return {"status": "started", "detail": "Netdisco sync started in the background."}
|
|
|
|
|
|
@router.get("/sync/status")
|
|
def sync_netdisco_status(current_user: User = Depends(RequireEditor)):
|
|
"""Poll target for the GUI: current/last manual-sync state + result stats."""
|
|
s = _ND_SYNC
|
|
state = ("running" if s["running"]
|
|
else "error" if s["error"]
|
|
else "done" if s["result"]
|
|
else "idle")
|
|
return {"state": state, "running": s["running"], "result": s["result"],
|
|
"error": s["error"], "finished_at": s["finished_at"]}
|