mirror of
https://github.com/hansjone/netx.git
synced 2026-10-09 02:00:46 +08:00
Stop recording unauthorized/forbidden/password-gate and silent refresh; keep login, logout, and failed-login style events. Co-authored-by: Cursor <cursoragent@cursor.com>
244 lines
6.5 KiB
Python
244 lines
6.5 KiB
Python
"""Async / sampled audit writes to reduce DB pressure on every request."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import queue
|
|
import threading
|
|
from typing import Any
|
|
|
|
from .config import settings
|
|
from .db import SessionLocal
|
|
|
|
_log = logging.getLogger("netx.audit.async")
|
|
|
|
_q: queue.Queue[dict[str, Any] | None] | None = None
|
|
_worker: threading.Thread | None = None
|
|
_lock = threading.Lock()
|
|
_counter = 0
|
|
_dropped = 0
|
|
|
|
# Middleware-tagged HTTP wrappers that duplicate semantic business audits.
|
|
_MIDDLEWARE_NOISE_ACTIONS = frozenset(
|
|
{
|
|
"audit.list",
|
|
"webcrt.get",
|
|
"webcrt.post",
|
|
"webcrt.put",
|
|
"webcrt.patch",
|
|
"webcrt.delete",
|
|
"users.get",
|
|
"api_tokens.get",
|
|
# Legacy auto-gate noise (no longer written; hide historical rows).
|
|
"auth.unauthorized",
|
|
"auth.password_change_required",
|
|
"auth.forbidden_scope",
|
|
}
|
|
)
|
|
|
|
# Frontend/session polls under /v1/auth/* — middleware tags them auth.{tail}.
|
|
# Keep failures (expired session etc.); drop successful chatter.
|
|
_AUTH_READ_NOISE_ACTIONS = frozenset(
|
|
{
|
|
"auth.me",
|
|
"auth.sessions",
|
|
"auth.refresh",
|
|
}
|
|
)
|
|
|
|
_ALWAYS_KEEP_PREFIXES = (
|
|
"auth.",
|
|
"users.",
|
|
"api_tokens.",
|
|
"ne.",
|
|
"port_traffic.",
|
|
"config_sync.",
|
|
)
|
|
|
|
|
|
def _sample_ok() -> bool:
|
|
"""When sample_n > 1, keep 1/N of leftover generic events."""
|
|
global _counter
|
|
n = int(getattr(settings, "audit_sample_n", 1) or 1)
|
|
if n <= 1:
|
|
return True
|
|
_counter += 1
|
|
return (_counter % n) == 0
|
|
|
|
|
|
def audit_should_persist(
|
|
*,
|
|
action: str,
|
|
method: str = "",
|
|
status_code: int = 0,
|
|
path: str = "",
|
|
) -> bool:
|
|
"""Decide whether a candidate audit event is worth writing.
|
|
|
|
Policy:
|
|
- Keep intentional ops: auth.login/logout/login_failed, users/tokens/NE/port_traffic/
|
|
config_sync, semantic webcrt session/command.
|
|
- Drop anonymous 401 / auto-gate noise (unauthorized, forbidden_scope, …).
|
|
- Drop successful ``auth.me`` / ``auth.sessions`` / ``auth.refresh`` polls.
|
|
- Drop successful GET/HEAD/OPTIONS ``http.*``; keep mutating http.* and failures.
|
|
- Drop middleware ``webcrt.{method}`` / ``audit.list``.
|
|
"""
|
|
del path # reserved for future path allow/deny lists
|
|
act = str(action or "")
|
|
method_u = str(method or "").upper()
|
|
code = int(status_code or 0)
|
|
|
|
if act in _MIDDLEWARE_NOISE_ACTIONS:
|
|
return False
|
|
|
|
# /v1/auth/me and /v1/auth/sessions are polled constantly by the UI.
|
|
if act in _AUTH_READ_NOISE_ACTIONS and code < 400:
|
|
return False
|
|
|
|
if act.startswith("webcrt.session_") or act == "webcrt.command":
|
|
return True
|
|
|
|
if any(act.startswith(p) for p in _ALWAYS_KEEP_PREFIXES):
|
|
return True
|
|
|
|
if code >= 400:
|
|
return True
|
|
|
|
if act.startswith("http."):
|
|
if method_u in ("GET", "HEAD", "OPTIONS") or act in ("http.get", "http.head", "http.options"):
|
|
return False
|
|
if method_u in ("POST", "PUT", "PATCH", "DELETE") or act in (
|
|
"http.post",
|
|
"http.put",
|
|
"http.patch",
|
|
"http.delete",
|
|
):
|
|
return True
|
|
return _sample_ok()
|
|
|
|
# e.g. ume.token.* — keep writes, drop successful reads
|
|
if act.startswith("ume."):
|
|
if method_u in ("GET", "HEAD", "OPTIONS"):
|
|
return False
|
|
return True
|
|
|
|
return _sample_ok()
|
|
|
|
|
|
def audit_queue_status() -> dict[str, int]:
|
|
q = _q
|
|
return {
|
|
"depth": int(q.qsize()) if q is not None else 0,
|
|
"dropped": int(_dropped),
|
|
"maxsize": max(100, int(getattr(settings, "audit_queue_max", 5000) or 5000)),
|
|
}
|
|
|
|
|
|
def _worker_loop() -> None:
|
|
assert _q is not None
|
|
while True:
|
|
item = _q.get()
|
|
if item is None:
|
|
break
|
|
try:
|
|
from .auth_service import write_audit
|
|
|
|
db = SessionLocal()
|
|
try:
|
|
write_audit(db, **item)
|
|
finally:
|
|
db.close()
|
|
except Exception:
|
|
_log.exception("async audit write failed")
|
|
|
|
|
|
def _ensure_worker() -> queue.Queue:
|
|
global _q, _worker
|
|
with _lock:
|
|
if _q is None:
|
|
maxsize = max(100, int(getattr(settings, "audit_queue_max", 5000) or 5000))
|
|
_q = queue.Queue(maxsize=maxsize)
|
|
_worker = threading.Thread(target=_worker_loop, name="netx-audit-writer", daemon=True)
|
|
_worker.start()
|
|
return _q
|
|
|
|
|
|
def shutdown_audit_worker(*, timeout_sec: float = 2.0) -> None:
|
|
global _q, _worker
|
|
with _lock:
|
|
q = _q
|
|
worker = _worker
|
|
if q is None:
|
|
return
|
|
try:
|
|
q.put_nowait(None)
|
|
except queue.Full:
|
|
pass
|
|
if worker is not None:
|
|
worker.join(timeout=max(0.1, float(timeout_sec)))
|
|
with _lock:
|
|
_q = None
|
|
_worker = None
|
|
|
|
|
|
def enqueue_audit(
|
|
*,
|
|
action: str,
|
|
actor_user_id: str = "",
|
|
actor_username: str = "",
|
|
method: str = "",
|
|
path: str = "",
|
|
status_code: int = 0,
|
|
client_ip: str = "",
|
|
user_agent: str = "",
|
|
detail: dict[str, Any] | None = None,
|
|
) -> None:
|
|
global _dropped
|
|
act = str(action or "")
|
|
if not audit_should_persist(action=act, method=method, status_code=status_code, path=path):
|
|
return
|
|
payload = {
|
|
"action": act,
|
|
"actor_user_id": actor_user_id,
|
|
"actor_username": actor_username,
|
|
"method": method,
|
|
"path": path,
|
|
"status_code": status_code,
|
|
"client_ip": client_ip,
|
|
"user_agent": user_agent,
|
|
"detail": detail or {},
|
|
}
|
|
if not bool(getattr(settings, "audit_async", True)):
|
|
from .auth_service import write_audit
|
|
|
|
db = SessionLocal()
|
|
try:
|
|
write_audit(db, **payload)
|
|
finally:
|
|
db.close()
|
|
return
|
|
try:
|
|
q = _ensure_worker()
|
|
try:
|
|
q.put_nowait(payload)
|
|
except queue.Full:
|
|
# Drop oldest then retry once to keep newest events.
|
|
try:
|
|
q.get_nowait()
|
|
_dropped += 1
|
|
except queue.Empty:
|
|
pass
|
|
try:
|
|
q.put_nowait(payload)
|
|
except queue.Full:
|
|
_dropped += 1
|
|
_log.warning("audit queue full; dropped event action=%s", act)
|
|
except Exception:
|
|
_log.exception("audit enqueue failed; falling back to sync")
|
|
from .auth_service import write_audit
|
|
|
|
db = SessionLocal()
|
|
try:
|
|
write_audit(db, **payload)
|
|
finally:
|
|
db.close()
|